mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-10 10:41:30 -10:00
Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d6e983523 | ||
|
|
38b8b0d8df | ||
|
|
6d82b94b69 | ||
|
|
8ef5132b5e | ||
|
|
a82e0e2ec5 | ||
|
|
377a24375d | ||
|
|
e6e5e4b6c6 | ||
|
|
55fc760abb | ||
|
|
86e8bb5c4d | ||
|
|
afb7c8ea63 | ||
|
|
f7aa81305a | ||
|
|
1a6fc7fd21 | ||
|
|
835a9c883c | ||
|
|
d62e9855b1 | ||
|
|
9660a47958 | ||
|
|
2d404a8664 | ||
|
|
83cbc8ff5c | ||
|
|
eab165b4e0 | ||
|
|
5bdf4835b0 | ||
|
|
1acc007e47 | ||
|
|
727dabe579 | ||
|
|
c70203b640 | ||
|
|
c088fa8b26 | ||
|
|
481cf172b5 | ||
|
|
ad7f03090a | ||
|
|
0e3b35e1e5 | ||
|
|
025e3e9255 | ||
|
|
d78a73452b | ||
|
|
eed2de8d66 | ||
|
|
e3808fc7c2 | ||
|
|
33786c14ba | ||
|
|
51ffd093b7 | ||
|
|
379709f13f | ||
|
|
0311bd3bf8 | ||
|
|
df87c3ba1f | ||
|
|
e8ef07f918 | ||
|
|
6a612a832c | ||
|
|
e09cb2353f | ||
|
|
bf54006f05 | ||
|
|
ad39bda16f | ||
|
|
283b3bbeea | ||
|
|
51e4ec0ab9 | ||
|
|
5593260635 | ||
|
|
f4f648567f | ||
|
|
7e365a3e0f | ||
|
|
7594a98e8c | ||
|
|
839123caf2 | ||
|
|
9153c66849 | ||
|
|
27ee6fde61 | ||
|
|
0574691d7e | ||
|
|
5948f8a7a3 |
No files matched your search
@@ -3,3 +3,6 @@ contact_links:
|
||||
- name: Download the latest release
|
||||
url: https://github.com/l0ng-ai/openidea/releases/latest
|
||||
about: Many problems are already fixed. Please check the latest version before reporting.
|
||||
- name: Report a security vulnerability
|
||||
url: https://github.com/l0ng-ai/openidea/security/advisories/new
|
||||
about: Please report security problems privately, not in a public issue.
|
||||
Generated
+43
-79
@@ -1522,7 +1522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1578,16 +1578,6 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c"
|
||||
dependencies = [
|
||||
"darling_core 0.13.4",
|
||||
"darling_macro 0.13.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
@@ -1608,20 +1598,6 @@ dependencies = [
|
||||
"darling_macro 0.24.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim 0.10.0",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.23.0"
|
||||
@@ -1631,7 +1607,7 @@ dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim 0.11.1",
|
||||
"strsim",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
@@ -1644,21 +1620,10 @@ dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim 0.11.1",
|
||||
"strsim",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835"
|
||||
dependencies = [
|
||||
"darling_core 0.13.4",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.23.0"
|
||||
@@ -2685,7 +2650,7 @@ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4333,7 +4298,7 @@ dependencies = [
|
||||
"ide-core",
|
||||
"ide-http",
|
||||
"ide-ssh",
|
||||
"oracle",
|
||||
"oracledb",
|
||||
"parking_lot",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
@@ -4370,6 +4335,7 @@ version = "0.1.40"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
"dunce",
|
||||
"ed25519-dalek",
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -5751,7 +5717,7 @@ checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -6487,15 +6453,6 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "odpic-sys"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "920b5474a5128a9f0232df5a0ffc50aaa5b077b29b8b06ab0131985ac82793ed"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -6609,29 +6566,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "oracle"
|
||||
version = "0.6.3"
|
||||
name = "oracledb"
|
||||
version = "26.0.0-beta.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3db40fe6e4df881b683691ade5ef1f7b1afd52aefa115581f7b92855524d7ec0"
|
||||
checksum = "27b98c7346f2bb5169ec4b2d4f066fb616529ac766b395440c5019fe88d1ef45"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"odpic-sys",
|
||||
"once_cell",
|
||||
"oracle_procmacro",
|
||||
"paste",
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oracle_procmacro"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad247f3421d57de56a0d0408d3249d4b1048a522be2013656d92f022c3d8af27"
|
||||
dependencies = [
|
||||
"darling 0.13.4",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
"aes",
|
||||
"base16ct",
|
||||
"base64ct",
|
||||
"cbc",
|
||||
"chrono",
|
||||
"pbkdf2",
|
||||
"pkcs8",
|
||||
"rand 0.10.3",
|
||||
"rustls",
|
||||
"sha2 0.11.1",
|
||||
"uuid",
|
||||
"webpki-roots",
|
||||
"whoami",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8918,12 +8870,6 @@ dependencies = [
|
||||
"unicode-properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -8960,7 +8906,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
@@ -9183,7 +9128,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"static_vcruntime",
|
||||
"strsim 0.11.1",
|
||||
"strsim",
|
||||
"tauri-utils",
|
||||
"tauri-winres",
|
||||
"walkdir",
|
||||
@@ -10723,6 +10668,15 @@ version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.14.7+wasi-0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c"
|
||||
dependencies = [
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.4+wasi-0.2.12"
|
||||
@@ -10732,6 +10686,15 @@ dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasite"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42"
|
||||
dependencies = [
|
||||
"wasi 0.14.7+wasi-0.2.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.129"
|
||||
@@ -11315,6 +11278,7 @@ dependencies = [
|
||||
"libc",
|
||||
"libredox",
|
||||
"objc2-system-configuration",
|
||||
"wasite",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
|
||||
+2
-1
@@ -106,7 +106,8 @@ rustls-platform-verifier = "0.7"
|
||||
# Until SQLx releases its libsqlite3-sys 0.39 update (RUSTSEC-2026-0459).
|
||||
sqlx = { version = "0.9", git = "https://github.com/transact-rs/sqlx", rev = "10462fa4414c6c7f1ed1d39bb31678e3b726878c", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
|
||||
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
|
||||
oracle = { version = "0.6", default-features = false }
|
||||
# Pure-Rust thin Oracle driver; pinned to the beta until it stabilizes.
|
||||
oracledb = "=26.0.0-beta.5"
|
||||
bb8 = "0.9"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
# OS credential stores
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
# Security policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please do not report security problems in public issues, pull requests or discussions.
|
||||
|
||||
Report them privately through GitHub:
|
||||
[Report a vulnerability](https://github.com/l0ng-ai/openidea/security/advisories/new)
|
||||
(repository **Security** tab → **Report a vulnerability**). Only the maintainers can see
|
||||
the report.
|
||||
|
||||
Include what you can of:
|
||||
|
||||
- the affected version, platform, and whether remote development (`openidea-server`)
|
||||
or plugins are involved;
|
||||
- steps to reproduce, or a proof of concept;
|
||||
- the impact you expect, and any fix you suggest.
|
||||
|
||||
We will acknowledge the report, keep you updated in the advisory, and agree on a
|
||||
disclosure date with you. Fixes are released as a new version, and the advisory is
|
||||
published once users can update.
|
||||
|
||||
## Supported versions
|
||||
|
||||
Security fixes go into the latest release. Please check that the problem still occurs
|
||||
there before reporting.
|
||||
@@ -1,5 +1,12 @@
|
||||
# Third-party licenses
|
||||
|
||||
## Flutter and Vite project icons
|
||||
|
||||
`src/ui/brands/flutter.svg` and `vite.svg` use the unmodified logo paths from
|
||||
[Simple Icons](https://github.com/simple-icons/simple-icons), retrieved through
|
||||
Iconify (`simple-icons:flutter`, `simple-icons:vite`) and filled with the project
|
||||
icon colors. Licensed under [CC0 1.0 Universal](https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md).
|
||||
|
||||
## Project Lombok
|
||||
|
||||
`java/lombok/lombok-1.18.48-openidea.3.jar` contains Project Lombok v1.18.48 with the Equinox
|
||||
|
||||
@@ -1722,7 +1722,7 @@ impl Task {
|
||||
format!("The agent process exited:\n{}", tail.join("\n"))
|
||||
}
|
||||
}
|
||||
RpcError::Server { message, .. } => message,
|
||||
RpcError::Server { message, data, .. } => with_details(message, data.as_ref()),
|
||||
e => e.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -2180,6 +2180,24 @@ fn usage_limit(e: &RpcError) -> Option<Option<f64>> {
|
||||
.then_some(None)
|
||||
}
|
||||
|
||||
/// An error's message with the detail ACP agents put in `data` (`{"details": …}` or a string):
|
||||
/// generic codes such as "Internal error" say nothing without it.
|
||||
fn with_details(message: String, data: Option<&Value>) -> String {
|
||||
let details = match data {
|
||||
Some(Value::String(text)) => text.as_str(),
|
||||
Some(data) => data.get("details").and_then(Value::as_str).unwrap_or_default(),
|
||||
None => "",
|
||||
}
|
||||
.trim();
|
||||
if details.is_empty() || message.contains(details) {
|
||||
message
|
||||
} else if message.trim().is_empty() {
|
||||
details.to_owned()
|
||||
} else {
|
||||
format!("{message}: {details}")
|
||||
}
|
||||
}
|
||||
|
||||
fn client_info() -> Value {
|
||||
json!({ "name": "OpenIDEA", "version": env!("CARGO_PKG_VERSION") })
|
||||
}
|
||||
|
||||
@@ -2568,3 +2568,17 @@ fn streamed_text_is_sent_as_appends() {
|
||||
let rewritten = entry_event("t", &mut streamed, None, 5, &agent(&format!("{}x!??", "é".repeat(4999))));
|
||||
assert!(matches!(rewritten, AgentEvent::Entry { .. }), "a rewritten end is sent whole");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_messages_keep_the_agents_details() {
|
||||
let details = json!({ "details": "spawn claude ENOENT" });
|
||||
assert_eq!(with_details("Internal error".into(), Some(&details)), "Internal error: spawn claude ENOENT");
|
||||
assert_eq!(
|
||||
with_details("Internal error".into(), Some(&json!("model not found"))),
|
||||
"Internal error: model not found"
|
||||
);
|
||||
assert_eq!(with_details(String::new(), Some(&details)), "spawn claude ENOENT");
|
||||
assert_eq!(with_details("spawn claude ENOENT".into(), Some(&details)), "spawn claude ENOENT");
|
||||
assert_eq!(with_details("Internal error".into(), Some(&json!({ "other": 1 }))), "Internal error");
|
||||
assert_eq!(with_details("Internal error".into(), None), "Internal error");
|
||||
}
|
||||
@@ -263,7 +263,12 @@ pub(super) fn maven_steps(
|
||||
return Err(IdeError::not_found("no Maven project found"));
|
||||
}
|
||||
let step = |p: &maven::MavenProject, extra: &[String]| {
|
||||
let mut s = BuildStep::new(&p.command, &["-B", "-Dstyle.color=never"], &subdir(root, &p.dir), Format::Maven);
|
||||
let mut s = BuildStep::new(
|
||||
&p.command,
|
||||
&["-B", "-Dstyle.color=never", maven::LOCAL_REPO_TRACKING_ARG],
|
||||
&subdir(root, &p.dir),
|
||||
Format::Maven,
|
||||
);
|
||||
s.args.extend(p.options.iter().cloned());
|
||||
s.args.extend(extra.iter().cloned());
|
||||
s.args.extend(goals.iter().map(|g| (*g).to_owned()));
|
||||
@@ -1044,9 +1049,21 @@ mod tests {
|
||||
assert_eq!(p.system, BuildSystem::Maven);
|
||||
assert_eq!(p.title, "demo");
|
||||
assert_eq!(p.steps.len(), 1);
|
||||
assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", "test-compile"]);
|
||||
assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", maven::LOCAL_REPO_TRACKING_ARG, "test-compile"]);
|
||||
let p = plan(&root, &model, BuildKind::Rebuild, Some(&root.join("core/src/main/java/A.java")), None).unwrap();
|
||||
assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", "-pl", "core", "-am", "clean", "test-compile"]);
|
||||
assert_eq!(
|
||||
p.steps[0].args,
|
||||
[
|
||||
"-B",
|
||||
"-Dstyle.color=never",
|
||||
maven::LOCAL_REPO_TRACKING_ARG,
|
||||
"-pl",
|
||||
"core",
|
||||
"-am",
|
||||
"clean",
|
||||
"test-compile"
|
||||
]
|
||||
);
|
||||
assert_eq!(p.steps[0].cwd, root);
|
||||
assert_eq!(p.title, "demo: build module 'core'");
|
||||
|
||||
@@ -1320,12 +1337,36 @@ mod tests {
|
||||
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]);
|
||||
assert_eq!(
|
||||
step.args,
|
||||
[
|
||||
"-B",
|
||||
"-Dstyle.color=never",
|
||||
maven::LOCAL_REPO_TRACKING_ARG,
|
||||
"-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!(
|
||||
plan.steps[0].args,
|
||||
["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", "clean", goal]
|
||||
[
|
||||
"-B",
|
||||
"-Dstyle.color=never",
|
||||
maven::LOCAL_REPO_TRACKING_ARG,
|
||||
"-s",
|
||||
"settings.xml",
|
||||
"-pl",
|
||||
"app",
|
||||
"-am",
|
||||
"clean",
|
||||
goal
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,7 +528,17 @@ mod tests {
|
||||
for (step, name) in plan.steps.iter().zip(["java", "kotlin"]) {
|
||||
assert_eq!(step.cwd, root.join(name));
|
||||
assert_eq!(step.program, format!("{name}-mvnw"));
|
||||
assert_eq!(step.args, ["-B", "-Dstyle.color=never", "-o", "-Peditor", "test-compile"]);
|
||||
assert_eq!(
|
||||
step.args,
|
||||
[
|
||||
"-B",
|
||||
"-Dstyle.color=never",
|
||||
crate::maven::LOCAL_REPO_TRACKING_ARG,
|
||||
"-o",
|
||||
"-Peditor",
|
||||
"test-compile"
|
||||
]
|
||||
);
|
||||
assert_eq!(step.env.get("JAVA_HOME").map(String::as_str), Some("/jdk"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,14 @@ use std::sync::{Mutex, OnceLock};
|
||||
|
||||
pub mod sources;
|
||||
|
||||
/// Lets the IDE's own Maven runs (Reload, Build) use what the local repository already
|
||||
/// has. Maven 3.9 re-resolves an artifact whose recorded origin repository (`_remote.repositories`)
|
||||
/// isn't declared in the build, such as a vendor jar downloaded through another project or
|
||||
/// installed by hand, and fails when no declared repository has it. Tracking under another
|
||||
/// file name makes such artifacts count as locally installed. Maven goals the user runs keep
|
||||
/// plain Maven's behaviour.
|
||||
pub const LOCAL_REPO_TRACKING_ARG: &str = "-Daether.enhancedLocalRepository.trackingFilename=openidea.repositories";
|
||||
|
||||
/// Phases shown under "Lifecycle" (IDEA's default list).
|
||||
pub const LIFECYCLE: &[&str] =
|
||||
&["clean", "validate", "compile", "test", "package", "verify", "install", "site", "deploy"];
|
||||
|
||||
@@ -341,7 +341,13 @@ pub fn sync_maven(
|
||||
// `-T1` keeps mvnd serial: its default parallel build interleaves the
|
||||
// `dependency:tree` output of concurrent modules, which the sync parses
|
||||
// as one stream. mvn is serial anyway, so this only pins the daemon.
|
||||
let mut args: Vec<String> = vec!["-B".into(), "-fae".into(), "-Dstyle.color=never".into(), "-T1".into()];
|
||||
let mut args: Vec<String> = vec![
|
||||
"-B".into(),
|
||||
"-fae".into(),
|
||||
"-Dstyle.color=never".into(),
|
||||
"-T1".into(),
|
||||
maven::LOCAL_REPO_TRACKING_ARG.into(),
|
||||
];
|
||||
args.extend(sync_maven_options(&options.maven_options));
|
||||
if options.offline {
|
||||
args.push("-o".into());
|
||||
|
||||
@@ -73,6 +73,8 @@ pub struct EditorAppearanceSettings {
|
||||
pub whitespace_inner: bool,
|
||||
pub whitespace_trailing: bool,
|
||||
pub indent_guides: bool,
|
||||
/// Colour indent guides by depth (`--syn-rainbow-*`), while `indent_guides` is on.
|
||||
pub rainbow_indent_guides: bool,
|
||||
/// Right margin and visual guides from Code Style.
|
||||
pub hard_wrap_guides: bool,
|
||||
pub intention_bulb: bool,
|
||||
@@ -95,6 +97,7 @@ impl Default for EditorAppearanceSettings {
|
||||
whitespace_inner: true,
|
||||
whitespace_trailing: true,
|
||||
indent_guides: true,
|
||||
rainbow_indent_guides: false,
|
||||
hard_wrap_guides: true,
|
||||
intention_bulb: true,
|
||||
distraction_free_width: 80,
|
||||
|
||||
@@ -1273,6 +1273,11 @@ pub struct ProjectStructureSettings {
|
||||
pub unmarked: Vec<String>,
|
||||
/// Content roots besides the project folder (absolute paths), shown and searched with it.
|
||||
pub content_roots: Vec<String>,
|
||||
/// Other projects (absolute paths) this project depends on; indexed and navigable like content roots.
|
||||
pub dependencies: Vec<String>,
|
||||
/// Per-workspace-member dependencies: member root (IPC path) → the roots it depends on.
|
||||
/// A member not listed here (or with an empty list) navigates only into itself.
|
||||
pub member_dependencies: BTreeMap<String, Vec<String>>,
|
||||
/// "Exclude files": `;`-separated name patterns (`*.log;tmp*`) excluded at any depth.
|
||||
pub exclude_files: String,
|
||||
/// Project compiler output directory (relative to project root or absolute; empty = default).
|
||||
@@ -1446,6 +1451,27 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_opens_diff_is_unset_by_default_and_round_trips() {
|
||||
let old: Settings = serde_json::from_str(r#"{"vcs":{"commit":{"nonModal":true}}}"#).unwrap();
|
||||
assert_eq!(old.vcs.commit.double_click_opens_diff, None);
|
||||
// The unset value shows up as `null` in the defaults: `SettingsStore::set` only
|
||||
// accepts a key path it finds there.
|
||||
let json = serde_json::to_value(Settings::default()).unwrap();
|
||||
assert_eq!(json["vcs"]["commit"]["doubleClickOpensDiff"], serde_json::Value::Null);
|
||||
for on in [true, false] {
|
||||
let mut settings = Settings::default();
|
||||
settings.vcs.commit.double_click_opens_diff = Some(on);
|
||||
let json = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(json["vcs"]["commit"]["doubleClickOpensDiff"], on);
|
||||
let restored: Settings = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(restored.vcs.commit.double_click_opens_diff, Some(on));
|
||||
}
|
||||
// Explicit `null` reads back as unset.
|
||||
let cleared: Settings = serde_json::from_str(r#"{"vcs":{"commit":{"doubleClickOpensDiff":null}}}"#).unwrap();
|
||||
assert_eq!(cleared.vcs.commit.double_click_opens_diff, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_meters_default_and_round_trip() {
|
||||
let old: Settings = serde_json::from_str(r#"{"agents":{"showCost":true}}"#).unwrap();
|
||||
|
||||
@@ -115,6 +115,13 @@ pub struct CommitSettings {
|
||||
pub wrap_at_margin: bool,
|
||||
/// Plugin before-commit checks turned off (`<plugin id>/<check id>`); the rest run.
|
||||
pub disabled_plugin_checks: Vec<String>,
|
||||
/// Double-click / Enter in the changes view: `None` follows the interface (the
|
||||
/// Commit tool window opens the diff, Local Changes the source file).
|
||||
///
|
||||
/// No `skip_serializing_if`: an unset value must still appear in
|
||||
/// `Settings::default()`, or `SettingsStore::set` rejects the key as unknown.
|
||||
#[specta(optional)]
|
||||
pub double_click_opens_diff: Option<bool>,
|
||||
}
|
||||
|
||||
impl Default for CommitSettings {
|
||||
@@ -135,6 +142,7 @@ impl Default for CommitSettings {
|
||||
show_right_margin: true,
|
||||
wrap_at_margin: false,
|
||||
disabled_plugin_checks: Vec::new(),
|
||||
double_click_opens_diff: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,13 +246,7 @@ impl Connection {
|
||||
|
||||
fn dispatch(inner: &Inner, incoming: &mpsc::UnboundedSender<Incoming>, body: &[u8]) {
|
||||
tracing::trace!(target: "ide_dap::wire", "<-- {}", String::from_utf8_lossy(body));
|
||||
let msg: Value = match serde_json::from_slice(body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("dap: unparsable message: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(msg) = parse_message(body) else { return };
|
||||
match msg.get("type").and_then(Value::as_str) {
|
||||
Some("response") => {
|
||||
let Some(request_seq) = msg.get("request_seq").and_then(Value::as_i64) else {
|
||||
@@ -285,6 +279,25 @@ fn dispatch(inner: &Inner, incoming: &mpsc::UnboundedSender<Incoming>, body: &[u
|
||||
}
|
||||
}
|
||||
|
||||
/// A message body as JSON. Some adapters pass the debuggee's output through as it was
|
||||
/// written: netcoredbg on Windows embeds console output in the ANSI code page (GBK on a
|
||||
/// Chinese system), which isn't valid UTF-8 and made the whole `output` event unparsable,
|
||||
/// so every line with non-ASCII text went missing. Such a body is decoded like other tool
|
||||
/// output (UTF-8, else the system code page) before parsing.
|
||||
fn parse_message(body: &[u8]) -> Option<Value> {
|
||||
let error = match serde_json::from_slice(body) {
|
||||
Ok(v) => return Some(v),
|
||||
Err(e) => e,
|
||||
};
|
||||
if std::str::from_utf8(body).is_err()
|
||||
&& let Ok(v) = serde_json::from_str(&ide_core::output_encoding::decode_tool_output(body))
|
||||
{
|
||||
return Some(v);
|
||||
}
|
||||
tracing::warn!("dap: unparsable message: {error}");
|
||||
None
|
||||
}
|
||||
|
||||
/// `message`, or the formatted `body.error` (`{format, variables}`) when present.
|
||||
fn error_message(msg: &Value) -> String {
|
||||
if let Some(err) = msg.pointer("/body/error")
|
||||
@@ -306,6 +319,22 @@ fn error_message(msg: &Value) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_output_events_with_non_utf8_text() {
|
||||
// "中文" in GBK inside an output event, as netcoredbg sends it on a Chinese Windows.
|
||||
let mut body = br#"{"seq":5,"type":"event","event":"output","body":{"category":"stdout","output":""#.to_vec();
|
||||
body.extend_from_slice(&[0xd6, 0xd0, 0xce, 0xc4]);
|
||||
body.extend_from_slice(br#"\n"}}"#);
|
||||
let msg = parse_message(&body).expect("parsed");
|
||||
assert_eq!(msg["event"], "output");
|
||||
let output = msg["body"]["output"].as_str().unwrap();
|
||||
assert!(output.ends_with('\n'));
|
||||
// Off Windows (UTF-8 code page) the bytes decode lossily; the line still arrives.
|
||||
#[cfg(not(windows))]
|
||||
assert!(output.starts_with(char::REPLACEMENT_CHARACTER));
|
||||
assert!(parse_message(b"{not json").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_structured_errors() {
|
||||
let msg =
|
||||
|
||||
@@ -20,7 +20,7 @@ parking_lot.workspace = true
|
||||
futures.workspace = true
|
||||
sqlx.workspace = true
|
||||
tiberius.workspace = true
|
||||
oracle.workspace = true
|
||||
oracledb.workspace = true
|
||||
bb8.workspace = true
|
||||
tokio-util = { workspace = true, features = ["compat"] }
|
||||
dirs.workspace = true
|
||||
|
||||
@@ -417,7 +417,7 @@ fn binary_type(name: &str, text: bool) -> bool {
|
||||
}
|
||||
|
||||
/// Bytes as text: UTF-8, else hex (grids) or with invalid sequences replaced (catalog names).
|
||||
fn bytes_text(bytes: Vec<u8>, mode: Cells) -> String {
|
||||
pub(crate) fn bytes_text(bytes: Vec<u8>, mode: Cells) -> String {
|
||||
match (String::from_utf8(bytes), mode) {
|
||||
(Ok(s), _) => s,
|
||||
(Err(e), Cells::Display) => hex(e.as_bytes()),
|
||||
@@ -1042,7 +1042,7 @@ pub enum Held {
|
||||
Mysql(Box<bb8::PooledConnection<'static, MysqlManager>>),
|
||||
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
|
||||
Clickhouse(crate::clickhouse::ClickhousePool),
|
||||
Oracle(crate::oracle::OracleConnection),
|
||||
Oracle(Box<crate::oracle::OracleConnection>),
|
||||
}
|
||||
|
||||
impl Held {
|
||||
@@ -1058,7 +1058,7 @@ impl Held {
|
||||
}
|
||||
Held::Clickhouse(p.clone())
|
||||
}
|
||||
Pool::Oracle(p) => Held::Oracle(p.get().await?),
|
||||
Pool::Oracle(p) => Held::Oracle(Box::new(p.get().await?)),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ impl DbTableKind {
|
||||
pub enum DbObjectKind {
|
||||
Function,
|
||||
Procedure,
|
||||
Package,
|
||||
Trigger,
|
||||
Sequence,
|
||||
}
|
||||
@@ -92,12 +93,21 @@ pub struct SequenceInfo {
|
||||
pub data_type: String,
|
||||
}
|
||||
|
||||
/// A schema's routines (procedures and functions) and sequences.
|
||||
/// An Oracle package (its procedures and functions are not listed separately).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbPackageInfo")]
|
||||
pub struct PackageInfo {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// A schema's routines (procedures and functions), packages and sequences.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename = "DbSchemaObjects")]
|
||||
pub struct SchemaObjects {
|
||||
pub routines: Vec<RoutineInfo>,
|
||||
pub packages: Vec<PackageInfo>,
|
||||
pub sequences: Vec<SequenceInfo>,
|
||||
}
|
||||
|
||||
@@ -296,7 +306,7 @@ fn through_tunnel(e: IdeError, source: &DataSource, via: Option<&str>) -> IdeErr
|
||||
|
||||
/// Options › Run keep-alive query: pings the pool until it is closed or dropped
|
||||
/// (a dropped SSH tunnel or an idle-killing proxy stays alive meanwhile).
|
||||
fn keep_alive(pool: &Arc<Pool>, every: Duration) {
|
||||
pub(crate) fn keep_alive(pool: &Arc<Pool>, every: Duration) {
|
||||
let pool = Arc::downgrade(pool);
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(every);
|
||||
@@ -853,6 +863,14 @@ impl DbManager {
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
if let Some(q) = sql::packages(driver, schema) {
|
||||
out.packages = pool
|
||||
.rows(&q, MAX_META_ROWS)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|r| Some(PackageInfo { name: r.into_iter().next().flatten()? }))
|
||||
.collect();
|
||||
}
|
||||
if let Some(q) = sql::sequences(driver, schema) {
|
||||
out.sequences = pool
|
||||
.rows(&q, MAX_META_ROWS)
|
||||
@@ -870,9 +888,29 @@ impl DbManager {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The definition of a routine (every overload), trigger or sequence.
|
||||
/// The definition of a routine (every overload), package (spec and body), trigger or sequence.
|
||||
pub async fn object_ddl(&self, id: &str, schema: &str, kind: DbObjectKind, name: &str) -> IdeResult<String> {
|
||||
let pool = self.pool(id).await?;
|
||||
if kind == DbObjectKind::Package {
|
||||
let mut defs: Vec<String> = Vec::new();
|
||||
for body in [false, true] {
|
||||
let Some(q) = sql::package_ddl(pool.driver(), schema, name, body) else { continue };
|
||||
match pool.rows(&q, MAX_META_ROWS).await {
|
||||
Ok(rows) => defs.extend(rows.into_iter().filter_map(|r| r.into_iter().next().flatten())),
|
||||
// A spec-only package has no body: the spec alone still opens.
|
||||
Err(e) if body => tracing::warn!("package body {schema}.{name}: {}", e.message),
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
if defs.is_empty() {
|
||||
return Err(IdeError::not_found(format!("{schema}.{name}")));
|
||||
}
|
||||
return Ok(defs
|
||||
.iter()
|
||||
.map(|d| format!("{};\n", d.trim_end().trim_end_matches(';')))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"));
|
||||
}
|
||||
let (q, column) = sql::object_ddl(pool.driver(), schema, kind, name)
|
||||
.ok_or_else(|| IdeError::invalid(format!("no {kind:?} objects in this database")))?;
|
||||
let defs: Vec<String> = pool
|
||||
|
||||
+605
-243
File diff suppressed because it is too large.
Load diff
@@ -20,7 +20,7 @@ pub enum DbDriver {
|
||||
/// Microsoft SQL Server.
|
||||
SqlServer,
|
||||
Clickhouse,
|
||||
/// Oracle Database (via Oracle Instant Client).
|
||||
/// Oracle Database (pure-Rust thin driver, no client library).
|
||||
Oracle,
|
||||
}
|
||||
|
||||
@@ -88,8 +88,6 @@ pub struct DbOptions {
|
||||
pub auto_disconnect_secs: Option<u32>,
|
||||
/// PostgreSQL: show only the configured database, its schemas right under the data source.
|
||||
pub single_database: bool,
|
||||
/// Oracle: directory of the Oracle Instant Client (empty: `ORACLE_HOME` / system search path).
|
||||
pub instant_client: String,
|
||||
}
|
||||
|
||||
/// Schemas tab: the databases / schemas the tree shows. Names may use `*` and `?`.
|
||||
|
||||
@@ -664,6 +664,32 @@ pub fn routines(driver: DbDriver, schema: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows: `name` of a schema's packages (Oracle only; their procedures and functions are not
|
||||
/// listed separately, as in IDEA).
|
||||
pub fn packages(driver: DbDriver, schema: &str) -> Option<String> {
|
||||
match driver {
|
||||
DbDriver::Oracle => Some(format!(
|
||||
"SELECT object_name FROM all_objects WHERE owner = {} AND object_type = 'PACKAGE' \
|
||||
AND object_name NOT LIKE 'BIN$%' ORDER BY 1",
|
||||
quote_literal(driver, schema)
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The statement reading a package's specification (`body` false) or body as DDL.
|
||||
pub fn package_ddl(driver: DbDriver, schema: &str, name: &str, body: bool) -> Option<String> {
|
||||
match driver {
|
||||
DbDriver::Oracle => Some(format!(
|
||||
"SELECT DBMS_METADATA.GET_DDL('{}', {}, {}) FROM DUAL",
|
||||
if body { "PACKAGE_BODY" } else { "PACKAGE" },
|
||||
quote_literal(driver, name),
|
||||
quote_literal(driver, schema)
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows: `name, data type` of a schema's sequences (PostgreSQL, Oracle).
|
||||
pub fn sequences(driver: DbDriver, schema: &str) -> Option<String> {
|
||||
match driver {
|
||||
@@ -963,6 +989,7 @@ mod tests {
|
||||
assert!(completion_columns(DbDriver::Oracle).contains("all_tab_columns"));
|
||||
assert!(triggers(DbDriver::Oracle, "hr", "t").contains("all_triggers"));
|
||||
assert!(routines(DbDriver::Oracle, "hr").unwrap().contains("all_objects"));
|
||||
assert!(packages(DbDriver::Oracle, "hr").unwrap().contains("object_type = 'PACKAGE'"));
|
||||
assert!(sequences(DbDriver::Oracle, "hr").unwrap().contains("all_sequences"));
|
||||
}
|
||||
|
||||
@@ -984,6 +1011,17 @@ mod tests {
|
||||
let (q, col) = object_ddl(DbDriver::Oracle, "hr", K::Procedure, "p").unwrap();
|
||||
assert!(q.contains("DBMS_METADATA.GET_DDL('PROCEDURE'") && col == 0);
|
||||
assert!(object_ddl(DbDriver::Oracle, "hr", K::Sequence, "q").unwrap().0.contains("all_sequences"));
|
||||
assert!(
|
||||
package_ddl(DbDriver::Oracle, "hr", "pkg", false)
|
||||
.unwrap()
|
||||
.contains("DBMS_METADATA.GET_DDL('PACKAGE', 'pkg', 'hr')")
|
||||
);
|
||||
assert!(
|
||||
package_ddl(DbDriver::Oracle, "hr", "pkg", true)
|
||||
.unwrap()
|
||||
.contains("DBMS_METADATA.GET_DDL('PACKAGE_BODY', 'pkg', 'hr')")
|
||||
);
|
||||
assert!(package_ddl(DbDriver::Postgres, "public", "pkg", false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1001,6 +1039,7 @@ mod tests {
|
||||
fn schema_objects_per_dialect() {
|
||||
use crate::DbObjectKind as K;
|
||||
assert!(routines(DbDriver::Sqlite, "main").is_none());
|
||||
assert!(packages(DbDriver::Sqlite, "main").is_none());
|
||||
assert!(sequences(DbDriver::Mysql, "db").is_none());
|
||||
assert!(routines(DbDriver::Postgres, "public").unwrap().contains("n.nspname = 'public'"));
|
||||
assert_eq!(
|
||||
|
||||
@@ -19,8 +19,10 @@ mod index;
|
||||
pub mod ledger;
|
||||
mod manifest;
|
||||
pub mod package;
|
||||
mod plugin_point;
|
||||
mod point;
|
||||
mod registry;
|
||||
mod resolve;
|
||||
mod ui;
|
||||
mod when;
|
||||
|
||||
@@ -30,7 +32,12 @@ pub use manifest::{
|
||||
ActivationEvent, CATEGORIES, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, MAX_CATEGORIES,
|
||||
Manifest, check_relative, engine_compatible, validate_item_id, validate_plugin_id, validate_point_id,
|
||||
};
|
||||
pub use plugin_point::{FieldType, PointDecl};
|
||||
pub use point::{Commit, Disposable, ExtensionPoint, MAX_PACKAGE_FILE, Package, Placement, Prepared, decode};
|
||||
pub use registry::{ContributionInfo, ExtensionRegistry, LOCALES_DIR, LOCALES_POINT, PreparedSet};
|
||||
pub use registry::{
|
||||
ChangeListener, Contribution, ContributionInfo, DeclaredPoint, ExtensionRegistry, LOCALES_DIR, LOCALES_POINT,
|
||||
PreparedSet,
|
||||
};
|
||||
pub use resolve::{Blocked, Dependency, DependencyState, Node, Plan, dependency_state, dependents, plan};
|
||||
pub use ui::{UiContribution, UiDecl, UiPoint, UiStore, to_json};
|
||||
pub use when::validate_when;
|
||||
@@ -26,6 +26,8 @@
|
||||
//! activated at start-up, as before.
|
||||
|
||||
use crate::capability::Capability;
|
||||
use crate::plugin_point::PointDecl;
|
||||
use crate::resolve::Dependency;
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
@@ -66,6 +68,10 @@ pub struct Manifest {
|
||||
pub wasm: Option<PathBuf>,
|
||||
pub activation: Vec<ActivationEvent>,
|
||||
pub capabilities: Capabilities,
|
||||
/// `[[depends]]`: plugins this one needs (API 1.5).
|
||||
pub depends: Vec<Dependency>,
|
||||
/// `[[extension-points]]`: what other plugins may contribute to this one (API 1.5).
|
||||
pub extension_points: Vec<PointDecl>,
|
||||
/// `contributes.<point>` → its declarations, validated by the extension points.
|
||||
pub contributes: toml::Table,
|
||||
}
|
||||
@@ -151,6 +157,7 @@ pub const EVENT_KINDS: &[&str] = &[
|
||||
"file-closed",
|
||||
"settings-changed",
|
||||
"input-mode-changed",
|
||||
"extensions-changed",
|
||||
"file-opened",
|
||||
"file-saved",
|
||||
"files-changed",
|
||||
@@ -210,6 +217,10 @@ struct Raw {
|
||||
#[serde(default)]
|
||||
permissions: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
depends: Vec<Dependency>,
|
||||
#[serde(default)]
|
||||
extension_points: Vec<PointDecl>,
|
||||
#[serde(default)]
|
||||
contributes: toml::Table,
|
||||
}
|
||||
|
||||
@@ -281,6 +292,8 @@ impl Manifest {
|
||||
wasm: raw.wasm,
|
||||
activation,
|
||||
capabilities,
|
||||
depends: raw.depends,
|
||||
extension_points: raw.extension_points,
|
||||
contributes: raw.contributes,
|
||||
};
|
||||
m.validate()?;
|
||||
@@ -357,6 +370,9 @@ impl Manifest {
|
||||
{
|
||||
self.needs_api(&format!("`{event}`"), 4)?;
|
||||
}
|
||||
if matches!(event, ActivationEvent::Event(kind) if kind == "extensions-changed") {
|
||||
self.needs_api("`on-event:extensions-changed`", 5)?;
|
||||
}
|
||||
if matches!(event, ActivationEvent::Event(kind) if kind == "files-changed") {
|
||||
self.needs_api("`on-event:files-changed`", 3)?;
|
||||
}
|
||||
@@ -367,6 +383,7 @@ impl Manifest {
|
||||
for c in &self.declared_categories {
|
||||
validate_item_id(c, "category")?;
|
||||
}
|
||||
self.validate_relations()?;
|
||||
for (point, decls) in &self.contributes {
|
||||
validate_point_id(point)?;
|
||||
if !matches!(decls, toml::Value::Array(_) | toml::Value::Table(_)) {
|
||||
@@ -376,6 +393,42 @@ impl Manifest {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `[[depends]]` and `[[extension-points]]`.
|
||||
fn validate_relations(&self) -> Result<(), String> {
|
||||
if !self.depends.is_empty() {
|
||||
self.needs_api("`[[depends]]`", 5)?;
|
||||
}
|
||||
if !self.extension_points.is_empty() {
|
||||
self.needs_api("`[[extension-points]]`", 5)?;
|
||||
}
|
||||
let mut required = BTreeSet::new();
|
||||
for dep in &self.depends {
|
||||
validate_plugin_id(&dep.id)?;
|
||||
if dep.id == self.id {
|
||||
return Err(format!("`depends`: {} cannot depend on itself", self.id));
|
||||
}
|
||||
if let Some(req) = &dep.version
|
||||
&& semver::VersionReq::parse(req).is_err()
|
||||
{
|
||||
return Err(format!(
|
||||
"`depends`: {:?} is not a version requirement like \"^1.2\" (plugin {})",
|
||||
req, dep.id
|
||||
));
|
||||
}
|
||||
if !required.insert(&dep.id) {
|
||||
return Err(format!("`depends`: {} is listed twice", dep.id));
|
||||
}
|
||||
}
|
||||
let mut declared = BTreeSet::new();
|
||||
for point in &self.extension_points {
|
||||
point.validate(&self.id)?;
|
||||
if !declared.insert(&point.id) {
|
||||
return Err(format!("extension point {} is declared twice", point.id));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The declarations of one extension point: `[[contributes.x]]` gives
|
||||
/// several, `[contributes.x]` one.
|
||||
pub fn declarations(&self, point: &str) -> Vec<toml::Value> {
|
||||
@@ -472,11 +525,12 @@ pub fn validate_item_id(id: &str, what: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
/// Extension point ids: `camelCase` or `kebab-case` words, dots allowed (`iconThemes`, `menus`).
|
||||
/// Points declared by plugins are `<plugin id>.<name>`, which may be longer and contain `_`.
|
||||
pub fn validate_point_id(id: &str) -> Result<(), String> {
|
||||
let ok = !id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id.len() <= 128
|
||||
&& id.starts_with(|c: char| c.is_ascii_lowercase())
|
||||
&& id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.'));
|
||||
&& id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_'));
|
||||
if ok { Ok(()) } else { Err(format!("invalid contribution point {id:?}")) }
|
||||
}
|
||||
|
||||
@@ -711,6 +765,86 @@ mod api14_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod relations_tests {
|
||||
use super::*;
|
||||
|
||||
const HEAD: &str = "id = \"acme.app\"\nname = \"App\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.5\"\n";
|
||||
|
||||
fn parse(rest: &str) -> Result<Manifest, String> {
|
||||
Manifest::parse(&format!("{HEAD}{rest}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_depends_and_extension_points() {
|
||||
let m = parse(
|
||||
r#"
|
||||
[[depends]]
|
||||
id = "acme.base"
|
||||
version = "^0.2"
|
||||
|
||||
[[depends]]
|
||||
id = "acme.extra"
|
||||
optional = true
|
||||
|
||||
[[extension-points]]
|
||||
id = "acme.app.hooks"
|
||||
required = ["name"]
|
||||
fields = { name = "string" }
|
||||
|
||||
[[contributes."acme.base.things"]]
|
||||
name = "x"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(m.depends.len(), 2);
|
||||
assert_eq!(m.depends[0].version.as_deref(), Some("^0.2"));
|
||||
assert!(m.depends[1].optional);
|
||||
assert_eq!(m.extension_points[0].id, "acme.app.hooks");
|
||||
assert_eq!(m.declarations("acme.base.things").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_new_keys_need_api_1_5() {
|
||||
let older =
|
||||
"id = \"acme.app\"\nname = \"App\"\nversion = \"1.0.0\"\nwasm = \"p.wasm\"\nengines.openidea = \"^1.4\"\n";
|
||||
let err = Manifest::parse(&format!("{older}[[depends]]\nid = \"acme.base\"\n")).unwrap_err();
|
||||
assert!(err.contains("new in plugin API 1.5"), "{err}");
|
||||
let err = Manifest::parse(&format!("{older}[[extension-points]]\nid = \"acme.app.x\"\n")).unwrap_err();
|
||||
assert!(err.contains("new in plugin API 1.5"), "{err}");
|
||||
let err = Manifest::parse(&format!("{older}activation = [\"on-event:extensions-changed\"]\n")).unwrap_err();
|
||||
assert!(err.contains("new in plugin API 1.5"), "{err}");
|
||||
let current = older.replace("^1.4", "^1.5");
|
||||
assert!(Manifest::parse(&format!("{current}activation = [\"on-event:extensions-changed\"]\n")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_bad_dependencies() {
|
||||
assert!(parse("[[depends]]\nid = \"acme.app\"\n").unwrap_err().contains("itself"));
|
||||
assert!(parse("[[depends]]\nid = \"Bad Id\"\n").unwrap_err().contains("invalid plugin id"));
|
||||
assert!(
|
||||
parse("[[depends]]\nid = \"acme.base\"\nversion = \"one\"\n").unwrap_err().contains("version requirement")
|
||||
);
|
||||
let twice = "[[depends]]\nid = \"acme.base\"\n[[depends]]\nid = \"acme.base\"\n";
|
||||
assert!(parse(twice).unwrap_err().contains("listed twice"));
|
||||
assert!(parse("[[depends]]\nid = \"acme.base\"\nunknown = 1\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_points_outside_the_plugins_namespace() {
|
||||
let err = parse("[[extension-points]]\nid = \"other.thing\"\n").unwrap_err();
|
||||
assert!(err.contains("acme.app.<name>"), "{err}");
|
||||
let twice = "[[extension-points]]\nid = \"acme.app.x\"\n[[extension-points]]\nid = \"acme.app.x\"\n";
|
||||
assert!(parse(twice).unwrap_err().contains("declared twice"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_ids_with_underscores_work_as_contribution_keys() {
|
||||
assert!(validate_point_id("my_tools.base.things").is_ok());
|
||||
assert!(validate_point_id("Bad").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod commands_api_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Extension points a plugin declares for other plugins to contribute to
|
||||
//! (`[[extension-points]]`), and the check of a contribution against them.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const MAX_FIELDS: usize = 32;
|
||||
const MAX_ID: usize = 128;
|
||||
/// Longest string a contribution may carry in one field.
|
||||
const MAX_TEXT: usize = 64 * 1024;
|
||||
const MAX_LIST: usize = 1000;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum FieldType {
|
||||
String,
|
||||
Boolean,
|
||||
Integer,
|
||||
Number,
|
||||
StringList,
|
||||
}
|
||||
|
||||
/// `[[extension-points]]`: a kind of contribution the declaring plugin's code reads.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PointDecl {
|
||||
/// `<plugin id>.<name>`.
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
/// Fields a contribution must have.
|
||||
#[serde(default)]
|
||||
pub required: Vec<String>,
|
||||
/// Every field a contribution may have; a contribution with another key is refused.
|
||||
#[serde(default)]
|
||||
pub fields: BTreeMap<String, FieldType>,
|
||||
}
|
||||
|
||||
impl PointDecl {
|
||||
pub fn validate(&self, plugin_id: &str) -> Result<(), String> {
|
||||
let prefix = format!("{plugin_id}.");
|
||||
let name = self.id.strip_prefix(&prefix).unwrap_or("");
|
||||
let name_ok = name.chars().next().is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
|
||||
&& name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'));
|
||||
if !name_ok || self.id.len() > MAX_ID {
|
||||
return Err(format!(
|
||||
"invalid extension point {:?}: use \"{plugin_id}.<name>\" with a-z, 0-9, '.', '-', '_' in the name",
|
||||
self.id
|
||||
));
|
||||
}
|
||||
if self.description.len() > 500 {
|
||||
return Err(format!("extension point {}: `description` is longer than 500 characters", self.id));
|
||||
}
|
||||
if self.fields.len() > MAX_FIELDS {
|
||||
return Err(format!("extension point {}: at most {MAX_FIELDS} fields", self.id));
|
||||
}
|
||||
for field in self.fields.keys() {
|
||||
let ok = field.chars().next().is_some_and(|c| c.is_ascii_lowercase())
|
||||
&& field.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
|
||||
&& field.len() <= 64;
|
||||
if !ok {
|
||||
return Err(format!("extension point {}: invalid field name {field:?}", self.id));
|
||||
}
|
||||
}
|
||||
for field in &self.required {
|
||||
if !self.fields.contains_key(field) {
|
||||
return Err(format!("extension point {}: required field {field:?} is not in `fields`", self.id));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Checks one contribution and returns it as JSON for the declaring plugin's code.
|
||||
pub fn check(&self, value: &toml::Value) -> Result<serde_json::Value, String> {
|
||||
let toml::Value::Table(table) = value else {
|
||||
return Err("a contribution is a table".into());
|
||||
};
|
||||
for key in table.keys() {
|
||||
if !self.fields.contains_key(key) {
|
||||
let known: Vec<&str> = self.fields.keys().map(String::as_str).collect();
|
||||
return Err(format!("unknown field {key:?} (this extension point has: {})", known.join(", ")));
|
||||
}
|
||||
}
|
||||
for key in &self.required {
|
||||
if !table.contains_key(key) {
|
||||
return Err(format!("missing required field {key:?}"));
|
||||
}
|
||||
}
|
||||
let mut out = serde_json::Map::new();
|
||||
for (key, value) in table {
|
||||
out.insert(key.clone(), convert(key, self.fields[key], value)?);
|
||||
}
|
||||
Ok(serde_json::Value::Object(out))
|
||||
}
|
||||
}
|
||||
|
||||
fn convert(key: &str, ty: FieldType, value: &toml::Value) -> Result<serde_json::Value, String> {
|
||||
use serde_json::Value as J;
|
||||
let wrong = |expected: &str| format!("field {key:?} must be {expected}");
|
||||
match (ty, value) {
|
||||
(FieldType::String, toml::Value::String(s)) => text(key, s).map(J::from),
|
||||
(FieldType::Boolean, toml::Value::Boolean(b)) => Ok(J::from(*b)),
|
||||
(FieldType::Integer, toml::Value::Integer(i)) => Ok(J::from(*i)),
|
||||
(FieldType::Number, toml::Value::Integer(i)) => Ok(J::from(*i)),
|
||||
(FieldType::Number, toml::Value::Float(f)) if f.is_finite() => Ok(J::from(*f)),
|
||||
(FieldType::StringList, toml::Value::Array(items)) => {
|
||||
if items.len() > MAX_LIST {
|
||||
return Err(format!("field {key:?} has more than {MAX_LIST} items"));
|
||||
}
|
||||
items
|
||||
.iter()
|
||||
.map(|item| match item {
|
||||
toml::Value::String(s) => text(key, s).map(J::from),
|
||||
_ => Err(wrong("a list of strings")),
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map(J::Array)
|
||||
}
|
||||
(FieldType::String, _) => Err(wrong("a string")),
|
||||
(FieldType::Boolean, _) => Err(wrong("true or false")),
|
||||
(FieldType::Integer, _) => Err(wrong("an integer")),
|
||||
(FieldType::Number, _) => Err(wrong("a number")),
|
||||
(FieldType::StringList, _) => Err(wrong("a list of strings")),
|
||||
}
|
||||
}
|
||||
|
||||
fn text(key: &str, s: &str) -> Result<String, String> {
|
||||
if s.len() > MAX_TEXT {
|
||||
return Err(format!("field {key:?} is longer than {} KiB", MAX_TEXT >> 10));
|
||||
}
|
||||
Ok(s.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn decl(src: &str) -> PointDecl {
|
||||
toml::from_str(src).unwrap()
|
||||
}
|
||||
|
||||
const COMPLETIONS: &str = r#"
|
||||
id = "acme.base.completions"
|
||||
description = "Extra completions"
|
||||
required = ["label"]
|
||||
fields = { label = "string", detail = "string", weight = "integer", scopes = "string-list" }
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn the_id_is_namespaced_by_the_declaring_plugin() {
|
||||
assert!(decl(COMPLETIONS).validate("acme.base").is_ok());
|
||||
let err = decl(COMPLETIONS).validate("acme.other").unwrap_err();
|
||||
assert!(err.contains("acme.other.<name>"), "{err}");
|
||||
assert!(decl("id = \"acme.base.\"").validate("acme.base").is_err());
|
||||
assert!(decl("id = \"acme.base.Bad Name\"").validate("acme.base").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_fields_must_be_declared() {
|
||||
let err = decl("id = \"p.x\"\nrequired = [\"a\"]").validate("p").unwrap_err();
|
||||
assert!(err.contains("required field \"a\""), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checks_a_contribution_and_converts_it_to_json() {
|
||||
let point = decl(COMPLETIONS);
|
||||
let contribution: toml::Value =
|
||||
toml::from_str("label = \"ew.x\"\nweight = 3\nscopes = [\"xml\", \"java\"]").unwrap();
|
||||
let json = point.check(&contribution).unwrap();
|
||||
assert_eq!(json, serde_json::json!({"label": "ew.x", "weight": 3, "scopes": ["xml", "java"]}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_what_the_point_does_not_declare() {
|
||||
let point = decl(COMPLETIONS);
|
||||
let check = |src: &str| point.check(&toml::from_str::<toml::Value>(src).unwrap()).unwrap_err();
|
||||
assert!(check("detail = \"x\"").contains("missing required field \"label\""));
|
||||
let err = check("label = \"a\"\nextra = 1");
|
||||
assert!(err.contains("unknown field \"extra\"") && err.contains("label"), "{err}");
|
||||
assert!(check("label = 1").contains("must be a string"));
|
||||
assert!(check("label = \"a\"\nweight = \"3\"").contains("must be an integer"));
|
||||
assert!(check("label = \"a\"\nscopes = [1]").contains("list of strings"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_take_integers_and_floats() {
|
||||
let point = decl("id = \"p.x\"\nfields = { ratio = \"number\" }");
|
||||
assert!(point.check(&toml::from_str::<toml::Value>("ratio = 2").unwrap()).is_ok());
|
||||
assert!(point.check(&toml::from_str::<toml::Value>("ratio = 0.5").unwrap()).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Extension points plus the contributions committed to them, accounted per plugin.
|
||||
|
||||
use crate::capability::Capability;
|
||||
use crate::plugin_point::PointDecl;
|
||||
use crate::point::{Disposable, ExtensionPoint, Package, Placement, Prepared};
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -23,7 +24,7 @@ pub struct ContributionInfo {
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
point: &'static str,
|
||||
point: String,
|
||||
key: Option<String>,
|
||||
label: String,
|
||||
disposable: Disposable,
|
||||
@@ -38,7 +39,7 @@ pub struct PreparedSet {
|
||||
pub files: Vec<PathBuf>,
|
||||
/// Declarations for extension points this host does not know (skipped).
|
||||
pub warnings: Vec<String>,
|
||||
items: Vec<(&'static str, Prepared)>,
|
||||
items: Vec<(String, Prepared)>,
|
||||
}
|
||||
|
||||
impl PreparedSet {
|
||||
@@ -61,11 +62,41 @@ impl std::fmt::Debug for PreparedSet {
|
||||
}
|
||||
}
|
||||
|
||||
/// What one plugin contributed to an extension point another plugin declared.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Contribution {
|
||||
/// The contributing plugin.
|
||||
pub plugin: String,
|
||||
/// The declaration as JSON, checked against the point's fields.
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
/// An extension point a plugin declared, with who contributes to it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DeclaredPoint {
|
||||
pub owner: String,
|
||||
pub decl: PointDecl,
|
||||
/// Plugin ids, in commit order, once each.
|
||||
pub contributors: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Dynamic {
|
||||
points: BTreeMap<String, (String, PointDecl)>,
|
||||
contributions: BTreeMap<String, Vec<Contribution>>,
|
||||
}
|
||||
|
||||
/// Called with (point id, owner plugin) after contributions to a declared point changed.
|
||||
pub type ChangeListener = Arc<dyn Fn(&str, &str) + Send + Sync>;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ExtensionRegistry {
|
||||
points: RwLock<BTreeMap<&'static str, Arc<dyn ExtensionPoint>>>,
|
||||
/// plugin id → its contributions in commit order.
|
||||
entries: Mutex<BTreeMap<String, Vec<Entry>>>,
|
||||
/// Points declared by plugins and what was contributed to them.
|
||||
dynamic: Arc<Mutex<Dynamic>>,
|
||||
listener: Arc<RwLock<Option<ChangeListener>>>,
|
||||
}
|
||||
|
||||
impl ExtensionRegistry {
|
||||
@@ -97,6 +128,111 @@ impl ExtensionRegistry {
|
||||
self.points.read().get(id).cloned()
|
||||
}
|
||||
|
||||
/// Registers the function told when contributions to a plugin-declared point change.
|
||||
pub fn set_change_listener(&self, listener: ChangeListener) {
|
||||
*self.listener.write() = Some(listener);
|
||||
}
|
||||
|
||||
/// The plugin that declared `point`, while it is committed.
|
||||
pub fn point_owner(&self, point: &str) -> Option<String> {
|
||||
self.dynamic.lock().points.get(point).map(|(owner, _)| owner.clone())
|
||||
}
|
||||
|
||||
/// What plugins contributed to `point`, in commit order. Empty unless a plugin declares it.
|
||||
pub fn contributions_to(&self, point: &str) -> Vec<Contribution> {
|
||||
let dynamic = self.dynamic.lock();
|
||||
if !dynamic.points.contains_key(point) {
|
||||
return Vec::new();
|
||||
}
|
||||
dynamic.contributions.get(point).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The points `owner` declared, with their contributors.
|
||||
pub fn declared_points(&self, owner: &str) -> Vec<DeclaredPoint> {
|
||||
let dynamic = self.dynamic.lock();
|
||||
dynamic
|
||||
.points
|
||||
.iter()
|
||||
.filter(|(_, (o, _))| o == owner)
|
||||
.map(|(id, (owner, decl))| {
|
||||
let mut contributors: Vec<String> = Vec::new();
|
||||
for c in dynamic.contributions.get(id).into_iter().flatten() {
|
||||
if !contributors.contains(&c.plugin) {
|
||||
contributors.push(c.plugin.clone());
|
||||
}
|
||||
}
|
||||
DeclaredPoint { owner: owner.clone(), decl: decl.clone(), contributors }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Makes `pkg`'s `[[extension-points]]` known: they take effect with the set's commit.
|
||||
fn prepare_declarations(&self, pkg: &Package) -> Vec<(String, Prepared)> {
|
||||
pkg.manifest
|
||||
.extension_points
|
||||
.iter()
|
||||
.map(|decl| {
|
||||
let (dynamic, owner, decl) = (self.dynamic.clone(), pkg.id().to_owned(), decl.clone());
|
||||
let id = decl.id.clone();
|
||||
let prepared = Prepared::new(format!("Extension point {id}"), move || {
|
||||
let id = decl.id.clone();
|
||||
dynamic.lock().points.insert(id.clone(), (owner.clone(), decl));
|
||||
Ok(Disposable::new(move || {
|
||||
dynamic.lock().points.remove(&id);
|
||||
}))
|
||||
})
|
||||
.key(format!("extension-point:{id}"));
|
||||
(id, prepared)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Validates `pkg`'s contributions to a point some plugin declares, or `None`
|
||||
/// when no plugin declares `point` (the caller then skips it as unknown).
|
||||
fn prepare_contributions(&self, pkg: &Package, point: &str) -> Option<Result<Vec<Prepared>, String>> {
|
||||
let declared = self.dynamic.lock().points.get(point).cloned();
|
||||
let own =
|
||||
pkg.manifest.extension_points.iter().find(|d| d.id == point).map(|d| (pkg.id().to_owned(), d.clone()));
|
||||
let (owner, decl) = declared.or(own)?;
|
||||
if owner != pkg.id() && !pkg.manifest.depends.iter().any(|d| d.id == owner) {
|
||||
return Some(Err(format!("{point} is declared by {owner}: add it to [[depends]]")));
|
||||
}
|
||||
let listener = self.listener.clone();
|
||||
let result = pkg
|
||||
.manifest
|
||||
.declarations(point)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, value)| {
|
||||
let data = decl.check(&value).map_err(|e| format!("contributions[{i}]: {e}"))?;
|
||||
let label = decl
|
||||
.required
|
||||
.first()
|
||||
.and_then(|field| data.get(field))
|
||||
.and_then(|v| v.as_str())
|
||||
.map_or_else(|| point.to_owned(), str::to_owned);
|
||||
let contribution = Contribution { plugin: pkg.id().to_owned(), data };
|
||||
let (dynamic, listener, point) = (self.dynamic.clone(), listener.clone(), point.to_owned());
|
||||
Ok(Prepared::new(label, move || {
|
||||
dynamic.lock().contributions.entry(point.clone()).or_default().push(contribution.clone());
|
||||
notify(&dynamic, &listener, &point);
|
||||
Ok(Disposable::new(move || {
|
||||
{
|
||||
let mut guard = dynamic.lock();
|
||||
if let Some(list) = guard.contributions.get_mut(&point)
|
||||
&& let Some(pos) = list.iter().position(|c| *c == contribution)
|
||||
{
|
||||
list.remove(pos);
|
||||
}
|
||||
}
|
||||
notify(&dynamic, &listener, &point);
|
||||
}))
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
Some(result)
|
||||
}
|
||||
|
||||
/// Validates every `contributes.<point>` declaration of `pkg`. Fails as a
|
||||
/// whole on the first invalid declaration; unknown points are skipped with
|
||||
/// a warning (forward compatibility).
|
||||
@@ -108,9 +244,24 @@ impl ExtensionRegistry {
|
||||
warnings: Vec::new(),
|
||||
items: Vec::new(),
|
||||
};
|
||||
// Declared points first: contributions to them (the plugin's own too) commit after.
|
||||
set.items.extend(self.prepare_declarations(pkg));
|
||||
for point_id in pkg.manifest.contributes.keys() {
|
||||
let Some(point) = self.point(point_id) else {
|
||||
let msg = format!("contributes.{point_id}: not supported by this version of the IDE; skipped");
|
||||
if let Some(result) = self.prepare_contributions(pkg, point_id) {
|
||||
let prepared = result.map_err(|e| format!("contributes.{point_id}: {e}"))?;
|
||||
set.items.extend(prepared.into_iter().map(|p| (point_id.clone(), p)));
|
||||
continue;
|
||||
}
|
||||
// Point ids are `<owner id>.<name>`, so the dependency that owns it can be named.
|
||||
let owner =
|
||||
pkg.manifest.depends.iter().find(|d| point_id.strip_prefix(&format!("{}.", d.id)).is_some());
|
||||
let msg = match owner {
|
||||
Some(owner) => {
|
||||
format!("contributes.{point_id}: {} is not running, so this is skipped until it is", owner.id)
|
||||
}
|
||||
None => format!("contributes.{point_id}: not supported by this version of the IDE; skipped"),
|
||||
};
|
||||
tracing::warn!(plugin = %pkg.id(), "{msg}");
|
||||
set.warnings.push(msg);
|
||||
continue;
|
||||
@@ -129,7 +280,7 @@ impl ExtensionRegistry {
|
||||
set.files.push(f.clone());
|
||||
}
|
||||
}
|
||||
set.items.push((point.id(), prepared));
|
||||
set.items.push((point.id().to_owned(), prepared));
|
||||
}
|
||||
}
|
||||
if let Some(point) = self.point(LOCALES_POINT)
|
||||
@@ -144,7 +295,7 @@ impl ExtensionRegistry {
|
||||
set.files.push(f.clone());
|
||||
}
|
||||
}
|
||||
set.items.push((point.id(), prepared));
|
||||
set.items.push((point.id().to_owned(), prepared));
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
@@ -201,7 +352,7 @@ impl ExtensionRegistry {
|
||||
.lock()
|
||||
.get(plugin)
|
||||
.map(|list| {
|
||||
list.iter().map(|e| ContributionInfo { point: e.point.into(), label: e.label.clone() }).collect()
|
||||
list.iter().map(|e| ContributionInfo { point: e.point.clone(), label: e.label.clone() }).collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -217,6 +368,14 @@ impl ExtensionRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
fn notify(dynamic: &Mutex<Dynamic>, listener: &RwLock<Option<ChangeListener>>, point: &str) {
|
||||
let owner = dynamic.lock().points.get(point).map(|(owner, _)| owner.clone());
|
||||
let callback = listener.read().clone();
|
||||
if let (Some(owner), Some(callback)) = (owner, callback) {
|
||||
callback(point, &owner);
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_prefix<'a>(e: &'a str, point: &str) -> &'a str {
|
||||
e.strip_prefix(&format!("contributes.{point}: ")).unwrap_or(e)
|
||||
}
|
||||
@@ -310,6 +469,122 @@ mod tests {
|
||||
assert!(reg.contributions("p").is_empty());
|
||||
}
|
||||
|
||||
fn pkg_of(id: &str, body: &str) -> Package {
|
||||
let src = format!("id = \"{id}\"\nname = \"N\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.5\"\n{body}");
|
||||
Package::new(Manifest::parse(&src).unwrap(), PathBuf::from("/nonexistent"))
|
||||
}
|
||||
|
||||
const BASE: &str = r#"
|
||||
[[extension-points]]
|
||||
id = "acme.base.things"
|
||||
required = ["name"]
|
||||
fields = { name = "string", weight = "integer" }
|
||||
"#;
|
||||
const PLUGIN: &str =
|
||||
"[[depends]]\nid = \"acme.base\"\n\n[[contributes.\"acme.base.things\"]]\nname = \"a\"\nweight = 2\n";
|
||||
|
||||
fn commit(reg: &ExtensionRegistry, pkg: &Package) {
|
||||
reg.commit(reg.prepare(pkg).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plugin_declares_a_point_and_a_dependent_contributes_to_it() {
|
||||
let reg = ExtensionRegistry::new();
|
||||
commit(®, &pkg_of("acme.base", BASE));
|
||||
assert_eq!(reg.point_owner("acme.base.things").as_deref(), Some("acme.base"));
|
||||
assert!(reg.contributions_to("acme.base.things").is_empty());
|
||||
|
||||
commit(®, &pkg_of("acme.more", PLUGIN));
|
||||
let got = reg.contributions_to("acme.base.things");
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].plugin, "acme.more");
|
||||
assert_eq!(got[0].data, serde_json::json!({"name": "a", "weight": 2}));
|
||||
assert_eq!(
|
||||
reg.contributions("acme.more")[0],
|
||||
ContributionInfo { point: "acme.base.things".into(), label: "a".into() }
|
||||
);
|
||||
let declared = reg.declared_points("acme.base");
|
||||
assert_eq!(declared.len(), 1);
|
||||
assert_eq!(declared[0].contributors, ["acme.more"]);
|
||||
|
||||
assert!(reg.dispose_plugin("acme.more"));
|
||||
assert!(reg.contributions_to("acme.base.things").is_empty());
|
||||
assert!(reg.dispose_plugin("acme.base"));
|
||||
assert_eq!(reg.point_owner("acme.base.things"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contributions_disappear_from_view_while_the_owner_is_gone() {
|
||||
let reg = ExtensionRegistry::new();
|
||||
commit(®, &pkg_of("acme.base", BASE));
|
||||
commit(®, &pkg_of("acme.more", PLUGIN));
|
||||
reg.dispose_plugin("acme.base");
|
||||
assert!(reg.contributions_to("acme.base.things").is_empty(), "nobody may read a point that is not declared");
|
||||
commit(®, &pkg_of("acme.base", BASE));
|
||||
assert_eq!(reg.contributions_to("acme.base.things").len(), 1, "back with the owner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contributing_needs_a_dependency_on_the_owner() {
|
||||
let reg = ExtensionRegistry::new();
|
||||
commit(®, &pkg_of("acme.base", BASE));
|
||||
let err =
|
||||
reg.prepare(&pkg_of("acme.rogue", "[[contributes.\"acme.base.things\"]]\nname = \"x\"\n")).unwrap_err();
|
||||
assert!(err.contains("acme.base") && err.contains("[[depends]]"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_contribution_is_checked_against_the_declared_fields() {
|
||||
let reg = ExtensionRegistry::new();
|
||||
commit(®, &pkg_of("acme.base", BASE));
|
||||
let bad = "[[depends]]\nid = \"acme.base\"\n[[contributes.\"acme.base.things\"]]\nweight = 1\n";
|
||||
let err = reg.prepare(&pkg_of("acme.more", bad)).unwrap_err();
|
||||
assert_eq!(err, "contributes.acme.base.things: contributions[0]: missing required field \"name\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_point_nobody_declares_is_skipped_with_a_warning() {
|
||||
let reg = ExtensionRegistry::new();
|
||||
let set = reg.prepare(&pkg_of("acme.more", PLUGIN)).unwrap();
|
||||
assert!(set.is_empty());
|
||||
assert!(set.warnings[0].contains("acme.base.things"), "{:?}", set.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_owner_is_named_instead_of_blaming_the_ide_version() {
|
||||
let reg = ExtensionRegistry::new();
|
||||
let set = reg.prepare(&pkg_of("acme.more", PLUGIN)).unwrap();
|
||||
let warning = &set.warnings[0];
|
||||
assert!(warning.contains("acme.base") && warning.contains("not running"), "{warning}");
|
||||
assert!(!warning.contains("this version of the IDE"), "{warning}");
|
||||
|
||||
let unrelated = "[[contributes.\"acme.nobody.things\"]]\nname = \"x\"\n";
|
||||
let set = reg.prepare(&pkg_of("acme.more", unrelated)).unwrap();
|
||||
assert!(set.warnings[0].contains("this version of the IDE"), "{:?}", set.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plugin_may_contribute_to_its_own_point() {
|
||||
let reg = ExtensionRegistry::new();
|
||||
let own = format!("{BASE}\n[[contributes.\"acme.base.things\"]]\nname = \"builtin\"\n");
|
||||
commit(®, &pkg_of("acme.base", &own));
|
||||
assert_eq!(reg.contributions_to("acme.base.things")[0].data["name"], "builtin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_owner_hears_about_changes_to_its_point() {
|
||||
let reg = ExtensionRegistry::new();
|
||||
let heard: Arc<Mutex<Vec<(String, String)>>> = Arc::default();
|
||||
let sink = heard.clone();
|
||||
reg.set_change_listener(Arc::new(move |point, owner| sink.lock().push((point.into(), owner.into()))));
|
||||
commit(®, &pkg_of("acme.base", BASE));
|
||||
assert!(heard.lock().is_empty(), "declaring is not a contribution");
|
||||
commit(®, &pkg_of("acme.more", PLUGIN));
|
||||
reg.dispose_plugin("acme.more");
|
||||
let expected = ("acme.base.things".to_owned(), "acme.base".to_owned());
|
||||
assert_eq!(*heard.lock(), [expected.clone(), expected]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_declarations_fail_prepare_and_unknown_points_warn() {
|
||||
let (reg, _) = setup();
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
//! Which plugins can run, and in which order, given what they depend on.
|
||||
|
||||
use semver::{Version, VersionReq};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
|
||||
/// `[[depends]]` of a manifest.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Dependency {
|
||||
pub id: String,
|
||||
/// Semver requirement on the dependency's version; none accepts any.
|
||||
#[serde(default)]
|
||||
pub version: Option<String>,
|
||||
/// The plugin runs without it; when present it starts first.
|
||||
#[serde(default)]
|
||||
pub optional: bool,
|
||||
}
|
||||
|
||||
impl Dependency {
|
||||
/// `acme.base ^1.0`.
|
||||
pub fn describe(&self) -> String {
|
||||
match &self.version {
|
||||
Some(v) => format!("{} {v}", self.id),
|
||||
None => self.id.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A plugin as the resolver sees it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Node<'a> {
|
||||
pub id: &'a str,
|
||||
pub version: &'a str,
|
||||
pub depends: &'a [Dependency],
|
||||
/// Not switched off by the user.
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// Why an enabled plugin cannot run.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
pub enum Blocked {
|
||||
/// A required plugin is not installed.
|
||||
Missing { requirement: String },
|
||||
/// A required plugin is switched off.
|
||||
Disabled { id: String },
|
||||
/// A required plugin is installed in a version that does not satisfy the requirement.
|
||||
Version { requirement: String, found: String },
|
||||
/// A required plugin cannot run itself.
|
||||
Dependency { id: String },
|
||||
/// The plugin is part of a dependency cycle (`a → b → a`).
|
||||
Cycle { ring: Vec<String> },
|
||||
}
|
||||
|
||||
impl fmt::Display for Blocked {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Missing { requirement } => write!(f, "Requires {requirement}, which is not installed"),
|
||||
Self::Disabled { id } => write!(f, "Requires {id}, which is disabled"),
|
||||
Self::Version { requirement, found } => {
|
||||
write!(f, "Requires {requirement}, but version {found} is installed")
|
||||
}
|
||||
Self::Dependency { id } => write!(f, "Requires {id}, which cannot run"),
|
||||
Self::Cycle { ring } => write!(f, "Part of a dependency cycle: {}", ring.join(" → ")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Plan {
|
||||
/// Plugins that can run, every plugin after the plugins it depends on.
|
||||
pub order: Vec<String>,
|
||||
/// Enabled plugins that cannot run. Disabled plugins are in neither list.
|
||||
pub blocked: BTreeMap<String, Blocked>,
|
||||
}
|
||||
|
||||
/// Decides, for every enabled plugin, whether its required plugins allow it to
|
||||
/// run and the order to start them in (the counterpart of IntelliJ's
|
||||
/// `PluginSetBuilder`: dependency graph, topological order, cycle report).
|
||||
pub fn plan(nodes: &[Node<'_>]) -> Plan {
|
||||
let by_id: BTreeMap<&str, &Node<'_>> = nodes.iter().map(|n| (n.id, n)).collect();
|
||||
let enabled: Vec<&Node<'_>> = by_id.values().copied().filter(|n| n.enabled).collect();
|
||||
let mut blocked: BTreeMap<String, Blocked> = BTreeMap::new();
|
||||
|
||||
for node in &enabled {
|
||||
for dep in node.depends.iter().filter(|d| !d.optional) {
|
||||
if let Some(reason) = direct_reason(dep, by_id.get(dep.id.as_str()).copied()) {
|
||||
blocked.entry(node.id.to_owned()).or_insert(reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cycles among plugins whose requirements are otherwise present.
|
||||
let candidates: BTreeSet<&str> = enabled.iter().map(|n| n.id).filter(|id| !blocked.contains_key(*id)).collect();
|
||||
let graph: Graph<'_> = candidates
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let deps = by_id[id].depends.iter().filter(|d| !d.optional).map(|d| d.id.as_str());
|
||||
(*id, deps.filter(|d| candidates.contains(d)).collect())
|
||||
})
|
||||
.collect();
|
||||
for component in components(&graph) {
|
||||
let cyclic = component.len() > 1 || graph[component[0]].contains(&component[0]);
|
||||
if cyclic {
|
||||
let ring = ring_of(&component, &graph);
|
||||
for id in component {
|
||||
blocked.insert(id.to_owned(), Blocked::Cycle { ring: ring.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A plugin that needs a blocked plugin is blocked too.
|
||||
loop {
|
||||
let mut changed = false;
|
||||
for node in &enabled {
|
||||
if blocked.contains_key(node.id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(dep) = node.depends.iter().find(|d| !d.optional && blocked.contains_key(&d.id)) {
|
||||
blocked.insert(node.id.to_owned(), Blocked::Dependency { id: dep.id.clone() });
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let runnable: BTreeSet<&str> = enabled.iter().map(|n| n.id).filter(|id| !blocked.contains_key(*id)).collect();
|
||||
Plan { order: topological(&runnable, &by_id), blocked }
|
||||
}
|
||||
|
||||
/// Plugins that need `id`, directly or through other plugins, whether or not
|
||||
/// they are enabled. Optional dependencies do not count.
|
||||
pub fn dependents(nodes: &[Node<'_>], id: &str) -> Vec<String> {
|
||||
let mut found: BTreeSet<String> = BTreeSet::new();
|
||||
let mut frontier = vec![id.to_owned()];
|
||||
while let Some(current) = frontier.pop() {
|
||||
for node in nodes {
|
||||
if node.depends.iter().any(|d| !d.optional && d.id == current) && found.insert(node.id.to_owned()) {
|
||||
frontier.push(node.id.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
found.remove(id);
|
||||
found.into_iter().collect()
|
||||
}
|
||||
|
||||
/// How one dependency stands right now, for the plugin details page.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DependencyState {
|
||||
Satisfied,
|
||||
Missing,
|
||||
Disabled,
|
||||
/// Installed, wrong version.
|
||||
Version,
|
||||
/// Installed and enabled, but cannot run itself.
|
||||
Blocked,
|
||||
}
|
||||
|
||||
pub fn dependency_state(dep: &Dependency, found: Option<&Node<'_>>, found_blocked: bool) -> DependencyState {
|
||||
match direct_reason(dep, found) {
|
||||
Some(Blocked::Missing { .. }) => DependencyState::Missing,
|
||||
Some(Blocked::Disabled { .. }) => DependencyState::Disabled,
|
||||
Some(Blocked::Version { .. }) => DependencyState::Version,
|
||||
_ if found_blocked => DependencyState::Blocked,
|
||||
_ => DependencyState::Satisfied,
|
||||
}
|
||||
}
|
||||
|
||||
fn direct_reason(dep: &Dependency, found: Option<&Node<'_>>) -> Option<Blocked> {
|
||||
let Some(target) = found else {
|
||||
return Some(Blocked::Missing { requirement: dep.describe() });
|
||||
};
|
||||
if !target.enabled {
|
||||
return Some(Blocked::Disabled { id: dep.id.clone() });
|
||||
}
|
||||
let want = dep.version.as_deref()?;
|
||||
let satisfied = VersionReq::parse(want)
|
||||
.ok()
|
||||
.zip(Version::parse(target.version).ok())
|
||||
.is_some_and(|(req, have)| req.matches(&have));
|
||||
(!satisfied).then(|| Blocked::Version { requirement: dep.describe(), found: target.version.to_owned() })
|
||||
}
|
||||
|
||||
/// Plugin id → the plugin ids it requires.
|
||||
type Graph<'a> = BTreeMap<&'a str, Vec<&'a str>>;
|
||||
|
||||
/// Strongly connected components (Tarjan) of `graph`.
|
||||
fn components<'a>(graph: &Graph<'a>) -> Vec<Vec<&'a str>> {
|
||||
struct State<'a, 'g> {
|
||||
index: usize,
|
||||
stack: Vec<&'a str>,
|
||||
info: BTreeMap<&'a str, (usize, usize, bool)>,
|
||||
out: Vec<Vec<&'a str>>,
|
||||
graph: &'g Graph<'a>,
|
||||
}
|
||||
fn visit<'a>(s: &mut State<'a, '_>, v: &'a str) {
|
||||
s.info.insert(v, (s.index, s.index, true));
|
||||
s.index += 1;
|
||||
s.stack.push(v);
|
||||
for &w in &s.graph[v] {
|
||||
match s.info.get(w).copied() {
|
||||
None => {
|
||||
visit(s, w);
|
||||
let low = s.info[w].1;
|
||||
let entry = s.info.get_mut(v).expect("visited");
|
||||
entry.1 = entry.1.min(low);
|
||||
}
|
||||
Some((index, _, true)) => {
|
||||
let entry = s.info.get_mut(v).expect("visited");
|
||||
entry.1 = entry.1.min(index);
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
let (index, low, _) = s.info[v];
|
||||
if index == low {
|
||||
let mut component = Vec::new();
|
||||
while let Some(w) = s.stack.pop() {
|
||||
s.info.get_mut(w).expect("visited").2 = false;
|
||||
component.push(w);
|
||||
if w == v {
|
||||
break;
|
||||
}
|
||||
}
|
||||
component.sort_unstable();
|
||||
s.out.push(component);
|
||||
}
|
||||
}
|
||||
let mut state = State { index: 0, stack: Vec::new(), info: BTreeMap::new(), out: Vec::new(), graph };
|
||||
for &id in graph.keys() {
|
||||
if !state.info.contains_key(id) {
|
||||
visit(&mut state, id);
|
||||
}
|
||||
}
|
||||
state.out
|
||||
}
|
||||
|
||||
/// One cycle through `component`, from its smallest id back to it.
|
||||
fn ring_of(component: &[&str], graph: &Graph<'_>) -> Vec<String> {
|
||||
let start = component[0];
|
||||
let mut ring = vec![start.to_owned()];
|
||||
let mut current = start;
|
||||
let mut seen: BTreeSet<&str> = BTreeSet::from([start]);
|
||||
loop {
|
||||
let mut next: Vec<&str> = graph[current].iter().copied().filter(|d| component.contains(d)).collect();
|
||||
next.sort_unstable();
|
||||
// Prefer closing the ring; otherwise take the smallest unseen member.
|
||||
let step =
|
||||
if next.contains(&start) { start } else { *next.iter().find(|d| !seen.contains(**d)).unwrap_or(&start) };
|
||||
ring.push(step.to_owned());
|
||||
if step == start {
|
||||
return ring;
|
||||
}
|
||||
seen.insert(step);
|
||||
current = step;
|
||||
}
|
||||
}
|
||||
|
||||
/// Kahn's algorithm over the runnable plugins; ties break by id. Optional
|
||||
/// dependencies order too, but never block: leftovers (an optional edge
|
||||
/// closing a loop) are appended by id.
|
||||
fn topological(runnable: &BTreeSet<&str>, by_id: &BTreeMap<&str, &Node<'_>>) -> Vec<String> {
|
||||
let mut waiting: BTreeMap<&str, BTreeSet<&str>> = runnable
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let deps = by_id[id].depends.iter().map(|d| d.id.as_str()).filter(|d| runnable.contains(d) && d != id);
|
||||
(*id, deps.collect())
|
||||
})
|
||||
.collect();
|
||||
let mut order: Vec<String> = Vec::with_capacity(runnable.len());
|
||||
while let Some(next) = waiting.iter().find(|(_, deps)| deps.is_empty()).map(|(id, _)| *id) {
|
||||
waiting.remove(next);
|
||||
for deps in waiting.values_mut() {
|
||||
deps.remove(next);
|
||||
}
|
||||
order.push(next.to_owned());
|
||||
}
|
||||
order.extend(waiting.keys().map(|id| (*id).to_owned()));
|
||||
order
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dep(id: &str) -> Dependency {
|
||||
Dependency { id: id.into(), version: None, optional: false }
|
||||
}
|
||||
|
||||
fn dep_v(id: &str, version: &str) -> Dependency {
|
||||
Dependency { id: id.into(), version: Some(version.into()), optional: false }
|
||||
}
|
||||
|
||||
fn optional(id: &str) -> Dependency {
|
||||
Dependency { id: id.into(), version: None, optional: true }
|
||||
}
|
||||
|
||||
fn node<'a>(id: &'a str, depends: &'a [Dependency]) -> Node<'a> {
|
||||
Node { id, version: "1.0.0", depends, enabled: true }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependencies_start_first_whatever_the_ids_sort_like() {
|
||||
let (a, z) = ([dep("z.base")], []);
|
||||
let plan = plan(&[node("a.app", &a), node("z.base", &z)]);
|
||||
assert_eq!(plan.order, ["z.base", "a.app"]);
|
||||
assert!(plan.blocked.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_plugins_keep_id_order() {
|
||||
let plan = plan(&[node("b", &[]), node("a", &[]), node("c", &[])]);
|
||||
assert_eq!(plan.order, ["a", "b", "c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_requirement_blocks_with_the_requirement_text() {
|
||||
let needs = [dep_v("acme.base", "^1.0")];
|
||||
let plan = plan(&[node("acme.app", &needs)]);
|
||||
assert!(plan.order.is_empty());
|
||||
assert_eq!(plan.blocked["acme.app"], Blocked::Missing { requirement: "acme.base ^1.0".into() });
|
||||
assert_eq!(plan.blocked["acme.app"].to_string(), "Requires acme.base ^1.0, which is not installed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_disabled_requirement_blocks_but_the_disabled_plugin_itself_is_not_blocked() {
|
||||
let needs = [dep("acme.base")];
|
||||
let mut base = node("acme.base", &[]);
|
||||
base.enabled = false;
|
||||
let plan = plan(&[base, node("acme.app", &needs)]);
|
||||
assert_eq!(plan.blocked["acme.app"], Blocked::Disabled { id: "acme.base".into() });
|
||||
assert!(!plan.blocked.contains_key("acme.base"));
|
||||
assert!(plan.order.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_installed_version_must_satisfy_the_requirement() {
|
||||
let needs = [dep_v("acme.base", "^0.2")];
|
||||
let mut base = node("acme.base", &[]);
|
||||
base.version = "0.3.0";
|
||||
let plan_bad = plan(&[base, node("acme.app", &needs)]);
|
||||
assert_eq!(
|
||||
plan_bad.blocked["acme.app"],
|
||||
Blocked::Version { requirement: "acme.base ^0.2".into(), found: "0.3.0".into() }
|
||||
);
|
||||
assert_eq!(plan_bad.blocked["acme.app"].to_string(), "Requires acme.base ^0.2, but version 0.3.0 is installed");
|
||||
|
||||
base.version = "0.2.7";
|
||||
let plan_ok = plan(&[base, node("acme.app", &needs)]);
|
||||
assert!(plan_ok.blocked.is_empty());
|
||||
assert_eq!(plan_ok.order, ["acme.base", "acme.app"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_propagates_to_everything_downstream() {
|
||||
let (b, c) = ([dep("a")], [dep("b")]);
|
||||
let plan = plan(&[node("b", &b), node("c", &c)]);
|
||||
assert_eq!(plan.blocked["b"], Blocked::Missing { requirement: "a".into() });
|
||||
assert_eq!(plan.blocked["c"], Blocked::Dependency { id: "b".into() });
|
||||
assert!(plan.order.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_optional_requirement_never_blocks_but_orders_when_present() {
|
||||
let app = [optional("acme.extra")];
|
||||
let alone = plan(&[node("acme.app", &app)]);
|
||||
assert_eq!(alone.order, ["acme.app"]);
|
||||
assert!(alone.blocked.is_empty());
|
||||
|
||||
let with = plan(&[node("acme.app", &app), node("acme.extra", &[])]);
|
||||
assert_eq!(with.order, ["acme.extra", "acme.app"]);
|
||||
|
||||
let mut off = node("acme.extra", &[]);
|
||||
off.enabled = false;
|
||||
let disabled = plan(&[node("acme.app", &app), off]);
|
||||
assert_eq!(disabled.order, ["acme.app"]);
|
||||
assert!(disabled.blocked.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cycle_blocks_its_members_and_names_the_ring() {
|
||||
let (a, b, d) = ([dep("b")], [dep("a")], [dep("a")]);
|
||||
let plan = plan(&[node("a", &a), node("b", &b), node("d", &d)]);
|
||||
let ring = Blocked::Cycle { ring: vec!["a".into(), "b".into(), "a".into()] };
|
||||
assert_eq!(plan.blocked["a"], ring);
|
||||
assert_eq!(plan.blocked["b"], ring);
|
||||
assert_eq!(ring.to_string(), "Part of a dependency cycle: a → b → a");
|
||||
assert_eq!(plan.blocked["d"], Blocked::Dependency { id: "a".into() });
|
||||
assert!(plan.order.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plugin_requiring_itself_is_a_cycle() {
|
||||
let a = [dep("a")];
|
||||
let plan = plan(&[node("a", &a)]);
|
||||
assert_eq!(plan.blocked["a"], Blocked::Cycle { ring: vec!["a".into(), "a".into()] });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_optional_loop_does_not_block_and_still_yields_every_plugin() {
|
||||
let (a, b) = ([optional("b")], [optional("a")]);
|
||||
let plan = plan(&[node("a", &a), node("b", &b)]);
|
||||
assert!(plan.blocked.is_empty());
|
||||
assert_eq!(plan.order.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependents_are_transitive_and_ignore_optional_edges() {
|
||||
let (b, c, d) = ([dep("a")], [dep("b")], [optional("a")]);
|
||||
let nodes = [node("a", &[]), node("b", &b), node("c", &c), node("d", &d)];
|
||||
assert_eq!(dependents(&nodes, "a"), ["b", "c"]);
|
||||
assert_eq!(dependents(&nodes, "c"), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_state_tells_the_details_page_what_to_say() {
|
||||
let want = dep_v("base", "^1.0");
|
||||
assert_eq!(dependency_state(&want, None, false), DependencyState::Missing);
|
||||
let mut base = node("base", &[]);
|
||||
assert_eq!(dependency_state(&want, Some(&base), false), DependencyState::Satisfied);
|
||||
assert_eq!(dependency_state(&want, Some(&base), true), DependencyState::Blocked);
|
||||
base.version = "2.0.0";
|
||||
assert_eq!(dependency_state(&want, Some(&base), false), DependencyState::Version);
|
||||
base.enabled = false;
|
||||
assert_eq!(dependency_state(&want, Some(&base), false), DependencyState::Disabled);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use ide_language::{FileReference, WebReference};
|
||||
use ide_workspace::file_refs::{self, PathCompletion};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A web reference no language server resolves and where it points: `class="card"` →
|
||||
/// `.card` selectors, `id` → `#id` selectors, `href="#top"` → the element, `var(--x)` →
|
||||
@@ -112,6 +112,68 @@ pub async fn web_goto(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeRes
|
||||
.await
|
||||
}
|
||||
|
||||
/// The file a Python import path names: `a.b.c` is `a/b/c.py(i)` or `a/b/c/__init__.py(i)`
|
||||
/// in the project or one of its content roots (an attached dependency project included);
|
||||
/// `.x` names it relative to `from`'s directory. Neither the language server (it analyses a
|
||||
/// single root) nor the definition index resolves a package segment, so ⌘B on the `a.b` of
|
||||
/// `from a.b import x` needs this.
|
||||
pub async fn nav_resolve_module(host: &Host, window: &str, name: String, from: IpcPath) -> IdeResult<Option<IpcPath>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let dots = name.len() - name.trim_start_matches('.').len();
|
||||
let parts: Vec<&str> = name.split('.').filter(|s| !s.is_empty()).collect();
|
||||
if parts.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let rel = parts.iter().collect::<PathBuf>();
|
||||
let bases: Vec<PathBuf> = if dots > 0 {
|
||||
let dir = PathBuf::from(from);
|
||||
let dir = dir.parent().map(Path::to_path_buf).unwrap_or(dir);
|
||||
let base = (0..dots.saturating_sub(1)).fold(dir, |p, _| p.parent().map(Path::to_path_buf).unwrap_or(p));
|
||||
vec![base]
|
||||
} else {
|
||||
crate::project::navigation_roots(&project, &from)
|
||||
};
|
||||
for base in bases {
|
||||
let dir = base.join(&rel);
|
||||
for candidate in
|
||||
[dir.with_extension("py"), dir.with_extension("pyi"), dir.join("__init__.py"), dir.join("__init__.pyi")]
|
||||
{
|
||||
if candidate.is_file() {
|
||||
return Ok(Some(IpcPath::from(candidate)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Index definitions named `name` in the other roots `from` may navigate into (its configured
|
||||
/// dependencies and extra content roots — see `navigation_roots`): the cross-root answer when
|
||||
/// the language server cannot resolve an import into an attached dependency project. `from`'s
|
||||
/// own root is left out: the server sees it, so a name it could not resolve there is not
|
||||
/// answered by every declaration that happens to share it (any `save` for `repo.save()`).
|
||||
pub async fn nav_index_definitions(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
name: String,
|
||||
from: IpcPath,
|
||||
) -> IdeResult<Vec<SymbolMatch>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let roots = crate::project::navigation_roots(&project, &from);
|
||||
let Some((_own, roots)) = roots.split_first() else { return Ok(Vec::new()) };
|
||||
if roots.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
// Filter inside the index: a cap applied before the root filter can fill with
|
||||
// own-root matches and evict the dependency roots entirely.
|
||||
Ok(project.index.symbols_named_within(&name, None, roots, 500))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// The existing file or directory a path written in `from` names (`href`, `url()`, a string literal).
|
||||
pub async fn fs_resolve_reference(
|
||||
host: &Host,
|
||||
@@ -181,6 +243,12 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("remove_recent", |c| async move { remove_recent(&c.host, &c.window, c.arg("path")?) });
|
||||
d.add("recent_files", |c| async move { recent_files(&c.host, &c.window) });
|
||||
d.add("web_goto", |c| async move { web_goto(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await });
|
||||
d.add("nav_resolve_module", |c| async move {
|
||||
nav_resolve_module(&c.host, &c.window, c.arg("name")?, c.arg("from")?).await
|
||||
});
|
||||
d.add("nav_index_definitions", |c| async move {
|
||||
nav_index_definitions(&c.host, &c.window, c.arg("name")?, c.arg("from")?).await
|
||||
});
|
||||
d.add("fs_resolve_reference", |c| async move {
|
||||
fs_resolve_reference(&c.host, &c.window, c.arg("from")?, c.arg("value")?).await
|
||||
});
|
||||
|
||||
@@ -221,7 +221,10 @@ pub fn set_settings(
|
||||
|| key == "editor.codeStyle"
|
||||
|| key.starts_with("editor.codeStyle.")
|
||||
|| key == "inspections"
|
||||
|| key.starts_with("inspections.");
|
||||
|| key.starts_with("inspections.")
|
||||
|| key == "files"
|
||||
|| key == "files.composeFileOverrides"
|
||||
|| key.starts_with("files.composeFileOverrides.");
|
||||
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
|
||||
.iter()
|
||||
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
|
||||
|
||||
@@ -530,6 +530,8 @@ pub(super) struct Shared {
|
||||
pub event_editors: RwLock<HashMap<String, ide_plugin::EditorContext>>,
|
||||
/// Validated UI declarations (settings schemas, declared views).
|
||||
pub ui: Arc<ide_extension::UiStore>,
|
||||
/// Extension points plugins declare and what was contributed to them.
|
||||
pub registry: Arc<ide_extension::ExtensionRegistry>,
|
||||
/// The UI language the windows report (BCP 47).
|
||||
pub locale: RwLock<String>,
|
||||
/// (plugin, window) → the log tabs its code opened there, oldest first:
|
||||
@@ -551,9 +553,11 @@ impl Shared {
|
||||
pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>>,
|
||||
ui: Arc<ide_extension::UiStore>,
|
||||
ledger: Arc<Mutex<Ledger>>,
|
||||
registry: Arc<ide_extension::ExtensionRegistry>,
|
||||
) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
ui,
|
||||
registry,
|
||||
locale: RwLock::new("en".into()),
|
||||
sink,
|
||||
workspace: RwLock::new(None),
|
||||
@@ -1332,6 +1336,21 @@ impl HostApi for PluginApi {
|
||||
host.mcp_executable.read().as_ref().map(|exe| ide_mcp::stdio_command(exe, &root))
|
||||
}
|
||||
|
||||
fn extension_contributions(&self, point: &str) -> Result<Vec<(String, String)>, HostError> {
|
||||
self.require_api("extension points", 5)?;
|
||||
match self.shared.registry.point_owner(point) {
|
||||
None => Err(HostError::NotFound(format!("no running plugin declares {point}"))),
|
||||
Some(owner) if owner != self.id() => Err(HostError::Denied(format!("extension point {point}"))),
|
||||
Some(_) => Ok(self
|
||||
.shared
|
||||
.registry
|
||||
.contributions_to(point)
|
||||
.into_iter()
|
||||
.map(|c| (c.plugin, c.data.to_string()))
|
||||
.collect()),
|
||||
}
|
||||
}
|
||||
|
||||
fn register_run_target(&self, root: &Path, target: ide_plugin::PluginRunTarget) -> Result<(), HostError> {
|
||||
self.register_target(root, target)
|
||||
}
|
||||
@@ -3217,7 +3236,14 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let sink: Arc<dyn PluginEventSink> = Arc::new(|_: Option<&str>, _: PluginEvent| {});
|
||||
let shared = Shared::new(sink, dir.join(".data"), pins, ide_extension::UiStore::new(), Arc::default());
|
||||
let shared = Shared::new(
|
||||
sink,
|
||||
dir.join(".data"),
|
||||
pins,
|
||||
ide_extension::UiStore::new(),
|
||||
Arc::default(),
|
||||
Arc::new(ide_extension::ExtensionRegistry::new()),
|
||||
);
|
||||
PluginApi { pkg: Package::new(manifest, dir.to_path_buf()), shared }
|
||||
}
|
||||
|
||||
@@ -3952,6 +3978,7 @@ mod workbench_tests {
|
||||
Arc::default(),
|
||||
ide_extension::UiStore::new(),
|
||||
Arc::default(),
|
||||
Arc::new(ide_extension::ExtensionRegistry::new()),
|
||||
);
|
||||
*shared.workspace.write() =
|
||||
Some(Arc::new(Workspace { root: root.into(), trusted, text: text.map(str::to_owned) }));
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Plugin dependencies at run time: which plugins may start, in which order,
|
||||
//! and what follows when a plugin they need goes away or comes back.
|
||||
|
||||
use super::{LoadedPlugin, PluginHost, PluginStatus, types::*};
|
||||
use ide_extension::{Blocked, Node, Plan, dependency_state, dependents, plan};
|
||||
use ide_plugin::WorkbenchEvent;
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Points whose contributions changed since the owners were last told.
|
||||
pub(super) type PendingChanges = Arc<parking_lot::Mutex<BTreeSet<String>>>;
|
||||
|
||||
impl PluginHost {
|
||||
/// The plan for the plugins as they are now. Plugins that are switched off,
|
||||
/// broken or being uninstalled cannot be depended on.
|
||||
fn dependency_plan(&self) -> Plan {
|
||||
self.with_nodes(|_, plan| plan.clone())
|
||||
}
|
||||
|
||||
fn with_nodes<R>(&self, f: impl FnOnce(&[Node<'_>], &Plan) -> R) -> R {
|
||||
let plugins: Vec<Arc<LoadedPlugin>> = self.plugins.read().values().cloned().collect();
|
||||
let disabled = self.state.lock().disabled.clone();
|
||||
let nodes: Vec<Node<'_>> = plugins
|
||||
.iter()
|
||||
.map(|p| Node {
|
||||
id: p.id(),
|
||||
version: &p.manifest().version,
|
||||
depends: &p.manifest().depends,
|
||||
enabled: !disabled.contains(p.id()) && !p.invalid && !p.meta.lock().uninstalling,
|
||||
})
|
||||
.collect();
|
||||
let plan = plan(&nodes);
|
||||
f(&nodes, &plan)
|
||||
}
|
||||
|
||||
/// Why `plugin` may not start now, if it may not.
|
||||
pub(super) fn blocked_reason(&self, plugin: &LoadedPlugin) -> Option<Blocked> {
|
||||
if let Some(reason) = self.dependency_plan().blocked.get(plugin.id()) {
|
||||
return Some(reason.clone());
|
||||
}
|
||||
// Enabled and present, but it could not start itself (failed, waiting for consent).
|
||||
let plugins = self.plugins.read();
|
||||
plugin
|
||||
.manifest()
|
||||
.depends
|
||||
.iter()
|
||||
.filter(|d| !d.optional)
|
||||
.find(|d| {
|
||||
plugins.get(&d.id).is_some_and(|p| {
|
||||
matches!(p.status(), PluginStatus::Failed | PluginStatus::NeedsConsent | PluginStatus::Blocked)
|
||||
})
|
||||
})
|
||||
.map(|d| Blocked::Dependency { id: d.id.clone() })
|
||||
}
|
||||
|
||||
/// Stops `plugin` and shows why it is not running.
|
||||
pub(super) fn set_blocked(&self, plugin: &LoadedPlugin, reason: &Blocked) {
|
||||
self.stop(plugin);
|
||||
let mut meta = plugin.meta.lock();
|
||||
meta.status = PluginStatus::Blocked;
|
||||
meta.error = Some(reason.to_string());
|
||||
meta.warnings.clear();
|
||||
tracing::info!(plugin = %plugin.id(), "{reason}");
|
||||
}
|
||||
|
||||
/// Orders `plugins` so each comes after the plugins it needs.
|
||||
pub(super) fn in_start_order(&self, mut plugins: Vec<Arc<LoadedPlugin>>) -> Vec<Arc<LoadedPlugin>> {
|
||||
let order = self.dependency_plan().order;
|
||||
let rank = |id: &str| order.iter().position(|o| o == id).unwrap_or(usize::MAX);
|
||||
plugins.sort_by(|a, b| rank(a.id()).cmp(&rank(b.id())).then_with(|| a.id().cmp(b.id())));
|
||||
plugins
|
||||
}
|
||||
|
||||
/// Brings every plugin in line with the dependency plan: plugins that lost
|
||||
/// something they need stop, plugins that can run again start. Called with
|
||||
/// the lifecycle lock held, after anything that adds, removes, enables or
|
||||
/// disables a plugin.
|
||||
pub(super) fn reconcile(&self) {
|
||||
let plan = self.dependency_plan();
|
||||
let get = |id: &str| self.plugins.read().get(id).cloned();
|
||||
for (id, reason) in &plan.blocked {
|
||||
if let Some(plugin) = get(id)
|
||||
&& (plugin.status() != PluginStatus::Blocked
|
||||
|| plugin.meta.lock().error.as_deref() != Some(&reason.to_string()))
|
||||
{
|
||||
self.set_blocked(&plugin, reason);
|
||||
}
|
||||
}
|
||||
for id in &plan.order {
|
||||
if let Some(plugin) = get(id)
|
||||
&& plugin.status() == PluginStatus::Blocked
|
||||
{
|
||||
self.start(&plugin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// After `id` started again (reloaded, updated): plugins that need it follow,
|
||||
/// and what they contributed is checked against its current extension points.
|
||||
pub(super) fn settle_dependents(&self, id: &str) {
|
||||
self.restart_dependents(id);
|
||||
self.reconcile();
|
||||
}
|
||||
|
||||
/// Starts again the running plugins that need `id`, and those that list it
|
||||
/// as an optional requirement: the latter skipped their contributions to its
|
||||
/// points while it was absent, so they take effect when it appears.
|
||||
fn restart_dependents(&self, id: &str) {
|
||||
let mut ids = self.with_nodes(|nodes, _| dependents(nodes, id));
|
||||
for optional in self.optional_dependents(id) {
|
||||
if !ids.contains(&optional) {
|
||||
ids.push(optional);
|
||||
}
|
||||
}
|
||||
for dependent in ids {
|
||||
self.restart_live(&dependent);
|
||||
}
|
||||
}
|
||||
|
||||
/// After `id` was enabled: the plugins that list it as an optional requirement
|
||||
/// start again (the ones that need it start through [`reconcile`](Self::reconcile)).
|
||||
pub(super) fn restart_optional_dependents(&self, id: &str) {
|
||||
for dependent in self.optional_dependents(id) {
|
||||
self.restart_live(&dependent);
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_dependents(&self, id: &str) -> Vec<String> {
|
||||
self.plugins
|
||||
.read()
|
||||
.values()
|
||||
.filter(|p| p.manifest().depends.iter().any(|d| d.optional && d.id == id))
|
||||
.map(|p| p.id().to_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn restart_live(&self, id: &str) {
|
||||
let Some(plugin) = self.plugins.read().get(id).cloned() else { return };
|
||||
if plugin.status().is_live() {
|
||||
self.stop(&plugin);
|
||||
self.start(&plugin);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tells plugins that declare an extension point that contributions to it changed.
|
||||
pub(super) fn flush_extension_changes(&self) {
|
||||
let points: Vec<String> = std::mem::take(&mut *self.extension_changes.lock()).into_iter().collect();
|
||||
if !points.is_empty() {
|
||||
self.post_events(None, vec![WorkbenchEvent::ExtensionsChanged(points)]);
|
||||
}
|
||||
}
|
||||
|
||||
/// `[[depends]]` with their state, who needs this plugin, and its extension points.
|
||||
pub(super) fn relations(
|
||||
&self,
|
||||
plugin: &LoadedPlugin,
|
||||
) -> (Vec<DependencyInfo>, Vec<DependentInfo>, Vec<ExtensionPointInfo>) {
|
||||
let disabled = self.state.lock().disabled.clone();
|
||||
let plugins = self.plugins.read();
|
||||
let depends = plugin
|
||||
.manifest()
|
||||
.depends
|
||||
.iter()
|
||||
.map(|dep| {
|
||||
let found = plugins.get(&dep.id);
|
||||
let node = found.map(|p| Node {
|
||||
id: p.id(),
|
||||
version: &p.manifest().version,
|
||||
depends: &[],
|
||||
enabled: !disabled.contains(p.id()) && !p.invalid,
|
||||
});
|
||||
let stuck = found.is_some_and(|p| {
|
||||
matches!(p.status(), PluginStatus::Failed | PluginStatus::NeedsConsent | PluginStatus::Blocked)
|
||||
});
|
||||
DependencyInfo {
|
||||
id: dep.id.clone(),
|
||||
name: found.map(|p| p.manifest().name.clone()),
|
||||
version: dep.version.clone(),
|
||||
optional: dep.optional,
|
||||
state: dependency_state(dep, node.as_ref(), stuck),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let all: Vec<Node<'_>> = plugins
|
||||
.values()
|
||||
.map(|p| Node {
|
||||
id: p.id(),
|
||||
version: &p.manifest().version,
|
||||
depends: &p.manifest().depends,
|
||||
enabled: !disabled.contains(p.id()) && !p.invalid,
|
||||
})
|
||||
.collect();
|
||||
let needing = dependents(&all, plugin.id())
|
||||
.into_iter()
|
||||
.filter_map(|id| plugins.get(&id))
|
||||
.map(|p| DependentInfo {
|
||||
id: p.id().to_owned(),
|
||||
name: p.manifest().name.clone(),
|
||||
enabled: !disabled.contains(p.id()),
|
||||
})
|
||||
.collect();
|
||||
let points = self
|
||||
.registry
|
||||
.declared_points(plugin.id())
|
||||
.into_iter()
|
||||
.map(|p| ExtensionPointInfo {
|
||||
id: p.decl.id,
|
||||
description: p.decl.description,
|
||||
contributors: p.contributors,
|
||||
})
|
||||
.collect();
|
||||
(depends, needing, points)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ mod api;
|
||||
mod auth;
|
||||
pub mod commit;
|
||||
mod database;
|
||||
mod dependencies;
|
||||
pub(crate) mod diagnostics;
|
||||
pub mod endpoints;
|
||||
mod event_paths;
|
||||
@@ -369,6 +370,8 @@ pub struct PluginHost {
|
||||
sink: Arc<dyn PluginEventSink>,
|
||||
limits: Limits,
|
||||
registry: Arc<ExtensionRegistry>,
|
||||
/// Extension points (declared by plugins) whose contributions changed, until their owners are told.
|
||||
extension_changes: dependencies::PendingChanges,
|
||||
ui: Arc<UiStore>,
|
||||
languages: Arc<PluginLanguages>,
|
||||
run_detectors: Arc<run_detectors::RunDetectors>,
|
||||
@@ -436,6 +439,11 @@ impl PluginHost {
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
let registry = Arc::new(ExtensionRegistry::new());
|
||||
let extension_changes: dependencies::PendingChanges = Arc::default();
|
||||
let changes = extension_changes.clone();
|
||||
registry.set_change_listener(Arc::new(move |point, _owner| {
|
||||
changes.lock().insert(point.to_owned());
|
||||
}));
|
||||
let ui = UiStore::new();
|
||||
let index = Arc::new(ContributionIndex::new(
|
||||
plugins_dir.join(".cache").join("contributions"),
|
||||
@@ -453,8 +461,14 @@ impl PluginHost {
|
||||
policy_pins.read().get(&(plugin.to_owned(), cap.to_string())).cloned()
|
||||
}));
|
||||
let ledger = Arc::new(Mutex::new(Ledger::load(&plugins_dir)));
|
||||
let shared =
|
||||
api::Shared::new(sink.clone(), plugins_dir.join(".data"), pins.clone(), ui.clone(), ledger.clone());
|
||||
let shared = api::Shared::new(
|
||||
sink.clone(),
|
||||
plugins_dir.join(".data"),
|
||||
pins.clone(),
|
||||
ui.clone(),
|
||||
ledger.clone(),
|
||||
registry.clone(),
|
||||
);
|
||||
let official_keys = market::built_in_official_keys();
|
||||
let market = RwLock::new(Self::load_market_index(&plugins_dir, &official_keys));
|
||||
let host = Self {
|
||||
@@ -470,6 +484,7 @@ impl PluginHost {
|
||||
sink,
|
||||
limits,
|
||||
registry,
|
||||
extension_changes,
|
||||
ui,
|
||||
languages,
|
||||
run_detectors,
|
||||
@@ -556,16 +571,22 @@ impl PluginHost {
|
||||
}
|
||||
let dirs = package_dirs(&self.plugins_dir);
|
||||
self.migrate_grants(&dirs);
|
||||
// Every package is listed before any starts: a plugin's requirements are
|
||||
// decided against all of them, and each starts after the plugins it needs.
|
||||
let mut to_start = Vec::new();
|
||||
for dir in dirs {
|
||||
if !self.is_loaded_from(&dir) {
|
||||
self.load_dir(&dir, PluginSource::Installed, None);
|
||||
to_start.extend(self.register_dir(&dir, PluginSource::Installed, None).1);
|
||||
}
|
||||
}
|
||||
for dir in self.links().paths {
|
||||
if !self.is_loaded_from(&dir) && dir.join(MANIFEST_FILE).is_file() {
|
||||
self.load_dir(&dir, PluginSource::Linked, None);
|
||||
to_start.extend(self.register_dir(&dir, PluginSource::Linked, None).1);
|
||||
}
|
||||
}
|
||||
for plugin in self.in_start_order(to_start) {
|
||||
self.start(&plugin);
|
||||
}
|
||||
let (done, cv) = &self.initial_load;
|
||||
*done.lock() = true;
|
||||
cv.notify_all();
|
||||
@@ -742,6 +763,7 @@ impl PluginHost {
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
self.reconcile();
|
||||
self.changed();
|
||||
}
|
||||
}
|
||||
@@ -841,6 +863,11 @@ impl PluginHost {
|
||||
} else {
|
||||
plugin.meta.lock().status = PluginStatus::Disabled;
|
||||
}
|
||||
// Plugins that need this one stop or start with it.
|
||||
self.reconcile();
|
||||
if enabled {
|
||||
self.restart_optional_dependents(id);
|
||||
}
|
||||
self.changed();
|
||||
Ok(())
|
||||
}
|
||||
@@ -853,6 +880,7 @@ impl PluginHost {
|
||||
let (dir, source, root) = (plugin.dir().to_path_buf(), plugin.source, plugin.dev_root.clone());
|
||||
self.unload(id);
|
||||
let new_id = self.load_dir(&dir, source, root);
|
||||
self.settle_dependents(&new_id);
|
||||
self.changed();
|
||||
self.info_of(&new_id)
|
||||
}
|
||||
@@ -971,6 +999,7 @@ impl PluginHost {
|
||||
.map(|p| p.meta.lock().error.clone().unwrap_or_else(|| "it failed to load".into()));
|
||||
if let Some(reason) = failed {
|
||||
self.roll_back(&id, &previous, &version, old_grant);
|
||||
self.settle_dependents(&id);
|
||||
self.changed();
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{} {version} could not start ({reason}); version {previous} was restored",
|
||||
@@ -983,6 +1012,7 @@ impl PluginHost {
|
||||
}
|
||||
}
|
||||
install::prune(&root);
|
||||
self.settle_dependents(&id);
|
||||
self.changed();
|
||||
self.info_of(&id)
|
||||
}
|
||||
@@ -1082,6 +1112,8 @@ impl PluginHost {
|
||||
if !self.state.lock().disabled.contains(id) {
|
||||
self.start(&plugin);
|
||||
}
|
||||
// Plugins that need this one follow it: running again, or blocked while it can't run.
|
||||
self.settle_dependents(id);
|
||||
self.changed();
|
||||
self.info_of(id)
|
||||
}
|
||||
@@ -1102,6 +1134,8 @@ impl PluginHost {
|
||||
if !self.state.lock().disabled.contains(id) {
|
||||
self.start(&plugin);
|
||||
}
|
||||
// Plugins that need this one follow it: running again, or blocked while it can't run.
|
||||
self.settle_dependents(id);
|
||||
self.changed();
|
||||
self.info_of(id)
|
||||
}
|
||||
@@ -1137,6 +1171,8 @@ impl PluginHost {
|
||||
if !self.state.lock().disabled.contains(id) {
|
||||
self.start(&plugin);
|
||||
}
|
||||
// Plugins that need this one follow it: running again, or blocked while it can't run.
|
||||
self.settle_dependents(id);
|
||||
self.changed();
|
||||
self.info_of(id)
|
||||
}
|
||||
@@ -1208,6 +1244,8 @@ impl PluginHost {
|
||||
if !self.state.lock().disabled.contains(id) {
|
||||
self.start(&plugin);
|
||||
}
|
||||
// Plugins that need this one follow it: running again, or blocked while it can't run.
|
||||
self.settle_dependents(id);
|
||||
self.changed();
|
||||
self.info_of(id)
|
||||
}
|
||||
@@ -1316,6 +1354,7 @@ impl PluginHost {
|
||||
return Err(e.into());
|
||||
}
|
||||
self.unload(id);
|
||||
self.reconcile();
|
||||
self.changed();
|
||||
Ok(())
|
||||
}
|
||||
@@ -1484,7 +1523,7 @@ impl PluginHost {
|
||||
PluginStatus::Disabled => Some(format!("plugin {id:?} is disabled")),
|
||||
PluginStatus::Static => Some(format!("plugin {id:?} has no code")),
|
||||
PluginStatus::NeedsConsent => Some(format!("plugin {id:?} is waiting for your permission")),
|
||||
PluginStatus::Failed => {
|
||||
PluginStatus::Failed | PluginStatus::Blocked => {
|
||||
Some(format!("plugin {id:?} is not running: {}", meta.error.as_deref().unwrap_or("failed")))
|
||||
}
|
||||
PluginStatus::Active | PluginStatus::Inactive => None,
|
||||
@@ -1858,6 +1897,15 @@ impl PluginHost {
|
||||
let keys = self.shared.changed_settings(&p.pkg, client);
|
||||
(!keys.is_empty()).then_some(WorkbenchEvent::SettingsChanged(keys))
|
||||
}
|
||||
// Only the plugin that declares a point hears about its contributions.
|
||||
WorkbenchEvent::ExtensionsChanged(points) => {
|
||||
let own: Vec<String> = points
|
||||
.iter()
|
||||
.filter(|point| self.registry.point_owner(point).as_deref() == Some(p.id()))
|
||||
.cloned()
|
||||
.collect();
|
||||
(!own.is_empty()).then_some(WorkbenchEvent::ExtensionsChanged(own))
|
||||
}
|
||||
_ => Some(e.clone()),
|
||||
})
|
||||
.collect();
|
||||
@@ -2388,6 +2436,21 @@ impl PluginHost {
|
||||
|
||||
/// Loads one package and starts it unless disabled. Returns the plugin id.
|
||||
fn load_dir(&self, dir: &Path, source: PluginSource, dev_root: Option<PathBuf>) -> String {
|
||||
let (id, to_start) = self.register_dir(dir, source, dev_root);
|
||||
if let Some(plugin) = to_start {
|
||||
self.start(&plugin);
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
/// Reads one package and lists it as a plugin. Returns its id and the plugin
|
||||
/// when it is to be started (valid, enabled, not already loaded).
|
||||
fn register_dir(
|
||||
&self,
|
||||
dir: &Path,
|
||||
source: PluginSource,
|
||||
dev_root: Option<PathBuf>,
|
||||
) -> (String, Option<Arc<LoadedPlugin>>) {
|
||||
let (manifest, error) = match Manifest::load(dir) {
|
||||
Ok(m) => (m, None),
|
||||
Err(e) => {
|
||||
@@ -2430,7 +2493,7 @@ impl PluginHost {
|
||||
dir.display(),
|
||||
existing.dir().display()
|
||||
);
|
||||
return id;
|
||||
return (id, None);
|
||||
}
|
||||
plugins.insert(id.clone(), plugin.clone());
|
||||
}
|
||||
@@ -2439,10 +2502,10 @@ impl PluginHost {
|
||||
let mut meta = plugin.meta.lock();
|
||||
meta.status = PluginStatus::Failed;
|
||||
meta.error = Some(e);
|
||||
} else if !self.state.lock().disabled.contains(&id) {
|
||||
self.start(&plugin);
|
||||
return (id, None);
|
||||
}
|
||||
id
|
||||
let enabled = !self.state.lock().disabled.contains(&id);
|
||||
(id, enabled.then_some(plugin))
|
||||
}
|
||||
|
||||
fn unload(&self, id: &str) {
|
||||
@@ -2469,6 +2532,9 @@ impl PluginHost {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(reason) = self.blocked_reason(plugin) {
|
||||
return self.set_blocked(plugin, &reason);
|
||||
}
|
||||
self.registry.dispose_plugin(plugin.id());
|
||||
self.clear_provider_diagnostics(plugin.id());
|
||||
if plugin.source == PluginSource::Installed
|
||||
@@ -2771,6 +2837,7 @@ impl PluginHost {
|
||||
self.refresh_pins(&ledger);
|
||||
}
|
||||
self.emit(None, PluginEvent::CommandsChanged);
|
||||
self.flush_extension_changes();
|
||||
let languages = self.languages.revision();
|
||||
if self.announced_languages.swap(languages, Ordering::AcqRel) != languages {
|
||||
self.emit(None, PluginEvent::LanguagesChanged);
|
||||
@@ -2929,6 +2996,8 @@ impl PluginHost {
|
||||
}
|
||||
|
||||
fn info(&self, p: &LoadedPlugin) -> PluginInfo {
|
||||
// Before `meta` below: this reads the other plugins' statuses.
|
||||
let (depends, dependents, extension_points) = self.relations(p);
|
||||
let m = p.manifest();
|
||||
// The ledger before a plugin's `meta`, as everywhere (`refresh_pins` holds the
|
||||
// ledger and locks each plugin's): the other order deadlocks.
|
||||
@@ -2974,6 +3043,9 @@ impl PluginHost {
|
||||
previous_version: (p.source == PluginSource::Installed)
|
||||
.then(|| install::previous_version(&self.plugins_dir.join(&m.id)))
|
||||
.flatten(),
|
||||
depends,
|
||||
dependents,
|
||||
extension_points,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3075,6 +3147,8 @@ fn placeholder_manifest(dir: &Path) -> Manifest {
|
||||
wasm: None,
|
||||
activation: Vec::new(),
|
||||
capabilities: Default::default(),
|
||||
depends: Vec::new(),
|
||||
extension_points: Vec::new(),
|
||||
contributes: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ pub enum PluginStatus {
|
||||
NeedsConsent,
|
||||
/// Failed to load, or failed repeatedly and was stopped for this session (see `error`).
|
||||
Failed,
|
||||
/// A plugin it requires is missing, disabled, the wrong version or cannot run
|
||||
/// (`error` says which); it starts when that is resolved.
|
||||
Blocked,
|
||||
}
|
||||
|
||||
impl PluginStatus {
|
||||
@@ -87,6 +90,45 @@ pub struct PluginInfo {
|
||||
pub publisher: Option<String>,
|
||||
/// The version an update replaced, to roll back to.
|
||||
pub previous_version: Option<String>,
|
||||
/// `[[depends]]`, each with how it stands now.
|
||||
pub depends: Vec<DependencyInfo>,
|
||||
/// Plugins that need this one, directly or through others.
|
||||
pub dependents: Vec<DependentInfo>,
|
||||
/// `[[extension-points]]` this plugin declares while it is live.
|
||||
pub extension_points: Vec<ExtensionPointInfo>,
|
||||
}
|
||||
|
||||
/// A plugin another one requires.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DependencyInfo {
|
||||
pub id: String,
|
||||
/// The installed plugin's name, when it is installed.
|
||||
pub name: Option<String>,
|
||||
/// The requirement as written (`^0.2`).
|
||||
pub version: Option<String>,
|
||||
pub optional: bool,
|
||||
pub state: ide_extension::DependencyState,
|
||||
}
|
||||
|
||||
/// A plugin that needs another one.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DependentInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// Switched off by the user: disabling the plugin it needs changes nothing for it.
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// An extension point a plugin declared.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExtensionPointInfo {
|
||||
pub id: String,
|
||||
pub description: String,
|
||||
/// Plugin ids contributing to it now.
|
||||
pub contributors: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -13,9 +13,11 @@ use ide_vfs::Watcher;
|
||||
use ide_workspace::documents::DocumentObserver;
|
||||
use ide_workspace::{DocumentInfo, Documents, FileMoves, LocalHistory, ProjectRoots, SettingsStore, SourceFolder};
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Everything bound to one open project folder.
|
||||
pub struct Project {
|
||||
@@ -1197,9 +1199,81 @@ fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cac
|
||||
extra_content_roots(settings, root, build_cache)
|
||||
}
|
||||
|
||||
/// The roots a file may navigate into: its own root (the workspace member containing it, or
|
||||
/// the project root) plus that root's configured dependencies. A workspace member depends
|
||||
/// only on the roots picked per member in Settings › Project Structure › Dependencies —
|
||||
/// being attached to the workspace never makes its code navigable by itself.
|
||||
/// Cached navigation roots per (project, file), with the instant they were computed.
|
||||
type NavigationRootsCache = HashMap<(PathBuf, PathBuf), (Instant, Vec<PathBuf>)>;
|
||||
|
||||
pub(crate) fn navigation_roots(project: &Project, from: &Path) -> Vec<PathBuf> {
|
||||
// Hover probes and import-segment clicks repeat these lookups for the same file,
|
||||
// and each uncached call re-reads the workspace file: memoize briefly. The short
|
||||
// TTL keeps settings and workspace edits near-immediate.
|
||||
static CACHE: Mutex<Option<NavigationRootsCache>> = Mutex::new(None);
|
||||
const TTL: Duration = Duration::from_secs(1);
|
||||
const CAP: usize = 256;
|
||||
let key = (project.root.clone(), from.to_path_buf());
|
||||
if let Some((at, roots)) = CACHE.lock().as_ref().and_then(|m| m.get(&key))
|
||||
&& at.elapsed() < TTL
|
||||
{
|
||||
return roots.clone();
|
||||
}
|
||||
let roots = navigation_roots_uncached(project, from);
|
||||
if let Some(map) = CACHE.lock().as_mut() {
|
||||
if map.len() >= CAP {
|
||||
map.retain(|_, (at, _)| at.elapsed() < TTL);
|
||||
}
|
||||
map.insert(key, (Instant::now(), roots.clone()));
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
fn navigation_roots_uncached(project: &Project, from: &Path) -> Vec<PathBuf> {
|
||||
let settings = project.settings.effective();
|
||||
let workspace = ide_workspace::workspace::Workspace::load(&project.root).ok().flatten();
|
||||
let own = workspace
|
||||
.as_ref()
|
||||
.and_then(|w| w.projects.iter().filter(|p| from.starts_with(p)).max_by_key(|p| p.components().count()).cloned())
|
||||
.unwrap_or_else(|| project.root.clone());
|
||||
let mut roots = vec![own.clone()];
|
||||
let own_key = ide_core::ipc_path::to_ipc(&own);
|
||||
if let Some(deps) = member_dependencies_for(&settings.project_structure.member_dependencies, &own_key) {
|
||||
for dep in deps {
|
||||
let dep = PathBuf::from(dep.trim());
|
||||
if dep.is_absolute() && dep.is_dir() && !roots.contains(&dep) {
|
||||
roots.push(dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
if workspace.is_none() {
|
||||
for root in content_roots(&settings, &project.root, &project.build_cache_dir()) {
|
||||
if !roots.contains(&root) {
|
||||
roots.push(root);
|
||||
}
|
||||
}
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
/// The dependency roots configured for `own`, keyed by IPC path. The settings UI writes
|
||||
/// keys as it received the member paths (IPC form); a hand-edited settings.toml may not,
|
||||
/// so compare normalized rather than only by exact match.
|
||||
fn member_dependencies_for<'a>(map: &'a BTreeMap<String, Vec<String>>, own_key: &str) -> Option<&'a Vec<String>> {
|
||||
if let Some(deps) = map.get(own_key) {
|
||||
return Some(deps);
|
||||
}
|
||||
map.iter().find(|(key, _)| ide_core::ipc_path::to_ipc(&PathBuf::from(key)) == own_key).map(|(_, deps)| deps)
|
||||
}
|
||||
|
||||
fn extra_content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cache: &Path) -> Vec<PathBuf> {
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
let user = settings.project_structure.content_roots.iter().map(|r| PathBuf::from(r.trim()));
|
||||
let user = settings
|
||||
.project_structure
|
||||
.content_roots
|
||||
.iter()
|
||||
.chain(settings.project_structure.dependencies.iter())
|
||||
.map(|r| PathBuf::from(r.trim()));
|
||||
for p in user.chain(ide_build::gradle_external_roots(root, build_cache, &settings.languages.java.gradle)) {
|
||||
if p.is_absolute() && p.is_dir() && !p.starts_with(root) && !out.contains(&p) {
|
||||
out.push(p);
|
||||
@@ -1811,3 +1885,34 @@ mod composer_tests {
|
||||
assert!(!changed(&[]));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod content_roots_tests {
|
||||
use super::content_roots;
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn member_dependencies_match_hand_written_keys() {
|
||||
let mut map = std::collections::BTreeMap::new();
|
||||
map.insert("B:\\proj\\member".to_string(), vec!["B:/dep".to_string()]);
|
||||
let deps = super::member_dependencies_for(&map, "B:/proj/member");
|
||||
assert_eq!(deps.map(Vec::as_slice), Some(["B:/dep".to_string()].as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_projects_join_the_content_roots() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("app");
|
||||
let shared = tmp.path().join("shared");
|
||||
let dep = tmp.path().join("lib");
|
||||
for dir in [&root, &shared, &dep] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
}
|
||||
let mut settings = ide_core::settings::Settings::default();
|
||||
settings.project_structure.content_roots = vec![shared.to_string_lossy().into_owned()];
|
||||
settings.project_structure.dependencies =
|
||||
vec![dep.to_string_lossy().into_owned(), root.to_string_lossy().into_owned()];
|
||||
let roots = content_roots(&settings, &root, &tmp.path().join("build"));
|
||||
assert_eq!(roots, vec![shared, dep]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! Plugin dependencies and plugin-declared extension points through a real WASM owner
|
||||
//! (`ide-plugin/tests/fixtures/ext-owner`) and data-only dependents.
|
||||
use ide_extension::DependencyState;
|
||||
use ide_host::plugins::{Consent, PluginHost, PluginInfo, PluginStatus};
|
||||
use ide_plugin::{Invocation, Limits};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
const OWNER: &str = "test.ext-owner";
|
||||
const POINT: &str = "test.ext-owner.items";
|
||||
|
||||
struct Fixture {
|
||||
_tmp: tempfile::TempDir,
|
||||
plugins_dir: PathBuf,
|
||||
host: PluginHost,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let plugins_dir = root.join("plugins");
|
||||
let host = Self::host_on(&plugins_dir);
|
||||
host.load_all();
|
||||
Self { _tmp: tmp, plugins_dir, host }
|
||||
}
|
||||
|
||||
fn host_on(plugins_dir: &Path) -> PluginHost {
|
||||
PluginHost::new(plugins_dir.to_owned(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap()
|
||||
}
|
||||
|
||||
/// Installs the real owner plugin, optionally at another version.
|
||||
fn install_owner(&self, version: &str) -> Result<PluginInfo, String> {
|
||||
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/ext-owner");
|
||||
let pkg = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(pkg.path().join("dist")).unwrap();
|
||||
std::fs::copy(fixture.join("dist/plugin.wasm"), pkg.path().join("dist/plugin.wasm")).unwrap();
|
||||
let toml = std::fs::read_to_string(fixture.join("plugin.toml")).unwrap();
|
||||
std::fs::write(
|
||||
pkg.path().join("plugin.toml"),
|
||||
toml.replace("version = \"0.1.0\"", &format!("version = \"{version}\"")),
|
||||
)
|
||||
.unwrap();
|
||||
self.install_dir(pkg.path())
|
||||
}
|
||||
|
||||
/// Installs a plugin made of a manifest only (contributions, no code).
|
||||
fn install_data(&self, id: &str, version: &str, body: &str) -> Result<PluginInfo, String> {
|
||||
let pkg = tempfile::tempdir().unwrap();
|
||||
let manifest =
|
||||
format!("id = \"{id}\"\nname = \"{id}\"\nversion = \"{version}\"\nengines.openidea = \"^1.5\"\n{body}");
|
||||
std::fs::write(pkg.path().join("plugin.toml"), manifest).unwrap();
|
||||
self.install_dir(pkg.path())
|
||||
}
|
||||
|
||||
fn install_dir(&self, dir: &Path) -> Result<PluginInfo, String> {
|
||||
self.host.install_from_dir(dir, &Consent::all()).map_err(|e| e.message)
|
||||
}
|
||||
|
||||
fn contributor(&self) -> Result<PluginInfo, String> {
|
||||
self.install_data(
|
||||
"test.ext-contributor",
|
||||
"0.2.0",
|
||||
&format!(
|
||||
"[[depends]]\nid = \"{OWNER}\"\nversion = \"^0.1\"\n\n[[contributes.\"{POINT}\"]]\nname = \"from-contributor\"\nweight = 7\n"
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn run(&self, plugin: &str, command: &str, arg: Option<&str>) -> Result<Option<String>, String> {
|
||||
let inv = Invocation { client: Some("main".into()), ..Default::default() };
|
||||
self.host.run_command(plugin, command, arg, inv).map_err(|e| e.message)
|
||||
}
|
||||
|
||||
fn info(&self, id: &str) -> PluginInfo {
|
||||
self.host.info_of(id).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dependent_contributes_data_that_reaches_the_owners_code() {
|
||||
let f = Fixture::new();
|
||||
f.install_owner("0.1.0").unwrap();
|
||||
f.contributor().unwrap();
|
||||
|
||||
let dump = f.run(OWNER, "dump", None).unwrap().unwrap();
|
||||
assert_eq!(dump, r#"test.ext-contributor:{"name":"from-contributor","weight":7}"#);
|
||||
|
||||
let owner = f.info(OWNER);
|
||||
assert_eq!(owner.extension_points.len(), 1);
|
||||
assert_eq!(owner.extension_points[0].id, POINT);
|
||||
assert_eq!(owner.extension_points[0].contributors, ["test.ext-contributor"]);
|
||||
assert_eq!(owner.dependents.iter().map(|d| d.id.as_str()).collect::<Vec<_>>(), ["test.ext-contributor"]);
|
||||
|
||||
let contributor = f.info("test.ext-contributor");
|
||||
assert_eq!(contributor.status, PluginStatus::Static);
|
||||
assert_eq!(contributor.depends.len(), 1);
|
||||
assert_eq!(contributor.depends[0].state, DependencyState::Satisfied);
|
||||
assert_eq!(contributor.depends[0].name.as_deref(), Some("Extension point owner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plugin_waits_for_the_plugin_it_needs_and_starts_when_it_arrives() {
|
||||
let f = Fixture::new();
|
||||
let waiting = f.contributor().unwrap();
|
||||
assert_eq!(waiting.status, PluginStatus::Blocked);
|
||||
assert_eq!(waiting.error.as_deref(), Some("Requires test.ext-owner ^0.1, which is not installed"));
|
||||
assert_eq!(waiting.depends[0].state, DependencyState::Missing);
|
||||
|
||||
f.install_owner("0.1.0").unwrap();
|
||||
assert_eq!(f.info("test.ext-contributor").status, PluginStatus::Static, "started with its dependency");
|
||||
assert_eq!(f.info("test.ext-contributor").error, None);
|
||||
assert!(f.run(OWNER, "dump", None).unwrap().unwrap().contains("from-contributor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_a_plugin_stops_what_needs_it_and_enabling_brings_it_back() {
|
||||
let f = Fixture::new();
|
||||
f.install_owner("0.1.0").unwrap();
|
||||
f.contributor().unwrap();
|
||||
|
||||
f.host.set_enabled(OWNER, false).unwrap();
|
||||
let blocked = f.info("test.ext-contributor");
|
||||
assert_eq!(blocked.status, PluginStatus::Blocked);
|
||||
assert_eq!(blocked.error.as_deref(), Some("Requires test.ext-owner, which is disabled"));
|
||||
assert_eq!(blocked.depends[0].state, DependencyState::Disabled);
|
||||
assert!(blocked.contributions.is_empty(), "its contributions are withdrawn");
|
||||
|
||||
f.host.set_enabled(OWNER, true).unwrap();
|
||||
assert_eq!(f.info("test.ext-contributor").status, PluginStatus::Static);
|
||||
assert!(f.run(OWNER, "dump", None).unwrap().unwrap().contains("from-contributor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_version_the_requirement_does_not_accept_blocks() {
|
||||
let f = Fixture::new();
|
||||
f.install_owner("0.3.0").unwrap();
|
||||
let info = f.contributor().unwrap();
|
||||
assert_eq!(info.status, PluginStatus::Blocked);
|
||||
assert_eq!(info.error.as_deref(), Some("Requires test.ext-owner ^0.1, but version 0.3.0 is installed"));
|
||||
assert_eq!(info.depends[0].state, DependencyState::Version);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updating_the_owner_checks_its_dependents_again() {
|
||||
let f = Fixture::new();
|
||||
f.install_owner("0.1.0").unwrap();
|
||||
f.contributor().unwrap();
|
||||
assert_eq!(f.info("test.ext-contributor").status, PluginStatus::Static);
|
||||
|
||||
f.install_owner("0.3.0").unwrap();
|
||||
assert_eq!(f.info("test.ext-contributor").status, PluginStatus::Blocked, "^0.1 no longer matches");
|
||||
|
||||
f.install_owner("0.1.5").unwrap();
|
||||
assert_eq!(f.info("test.ext-contributor").status, PluginStatus::Static);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugins_that_need_each_other_do_not_start_and_say_so() {
|
||||
let f = Fixture::new();
|
||||
let a = f.install_data("test.cycle-a", "1.0.0", "[[depends]]\nid = \"test.cycle-b\"\n").unwrap();
|
||||
assert_eq!(a.status, PluginStatus::Blocked, "b is not installed yet");
|
||||
let b = f.install_data("test.cycle-b", "1.0.0", "[[depends]]\nid = \"test.cycle-a\"\n").unwrap();
|
||||
for info in [b, f.info("test.cycle-a")] {
|
||||
assert_eq!(info.status, PluginStatus::Blocked, "{}", info.id);
|
||||
assert_eq!(
|
||||
info.error.as_deref(),
|
||||
Some("Part of a dependency cycle: test.cycle-a → test.cycle-b → test.cycle-a"),
|
||||
"{}",
|
||||
info.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_restart_of_the_app_starts_plugins_after_the_ones_they_need() {
|
||||
let f = Fixture::new();
|
||||
f.install_owner("0.1.0").unwrap();
|
||||
f.contributor().unwrap();
|
||||
f.host.shutdown();
|
||||
|
||||
// "test.ext-contributor" sorts before "test.ext-owner": directory order would start it first.
|
||||
let again = Fixture::host_on(&f.plugins_dir);
|
||||
again.load_all();
|
||||
assert_eq!(again.info_of("test.ext-contributor").unwrap().status, PluginStatus::Static);
|
||||
let inv = Invocation { client: Some("main".into()), ..Default::default() };
|
||||
let dump = again.run_command(OWNER, "dump", None, inv).unwrap().unwrap();
|
||||
assert!(dump.contains("from-contributor"), "{dump}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_declaring_plugin_reads_a_point_and_unknown_points_are_not_found() {
|
||||
let f = Fixture::new();
|
||||
f.install_owner("0.1.0").unwrap();
|
||||
f.install_data(
|
||||
"test.other",
|
||||
"1.0.0",
|
||||
"[[extension-points]]\nid = \"test.other.things\"\nfields = { name = \"string\" }\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(f.run(OWNER, "read", Some(POINT)).unwrap().as_deref(), Some("0"));
|
||||
let denied = f.run(OWNER, "read", Some("test.other.things")).unwrap_err();
|
||||
assert!(denied.contains("denied") || denied.contains("permission"), "{denied}");
|
||||
let missing = f.run(OWNER, "read", Some("test.nobody.things")).unwrap_err();
|
||||
assert!(missing.contains("no running plugin declares"), "{missing}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contributions_are_checked_against_the_fields_the_point_declares() {
|
||||
let f = Fixture::new();
|
||||
f.install_owner("0.1.0").unwrap();
|
||||
let body = format!("[[depends]]\nid = \"{OWNER}\"\n\n[[contributes.\"{POINT}\"]]\nweight = 1\n");
|
||||
let err = f.install_data("test.bad", "1.0.0", &body).unwrap_err();
|
||||
assert!(err.contains("missing required field \"name\""), "{err}");
|
||||
|
||||
let body =
|
||||
format!("[[depends]]\nid = \"{OWNER}\"\n\n[[contributes.\"{POINT}\"]]\nname = \"x\"\ncolour = \"red\"\n");
|
||||
let err = f.install_data("test.bad", "1.0.0", &body).unwrap_err();
|
||||
assert!(err.contains("unknown field \"colour\""), "{err}");
|
||||
|
||||
let body = format!("[[contributes.\"{POINT}\"]]\nname = \"x\"\n");
|
||||
let err = f.install_data("test.bad", "1.0.0", &body).unwrap_err();
|
||||
assert!(err.contains("[[depends]]"), "contributing needs the dependency: {err}");
|
||||
}
|
||||
|
||||
/// An optional requirement never blocks, but the plugin still contributes once the plugin
|
||||
/// appears (IntelliJ loads an optional `depends` part when its target is installed or enabled later).
|
||||
#[test]
|
||||
fn an_optional_dependent_contributes_when_its_target_appears_later() {
|
||||
let f = Fixture::new();
|
||||
let body = format!(
|
||||
"[[depends]]\nid = \"{OWNER}\"\noptional = true\n\n[[contributes.\"{POINT}\"]]\nname = \"optional-one\"\n"
|
||||
);
|
||||
let early = f.install_data("test.optional", "1.0.0", &body).unwrap();
|
||||
assert_eq!(early.status, PluginStatus::Static, "an optional requirement never blocks: {:?}", early.error);
|
||||
assert_eq!(early.depends[0].state, DependencyState::Missing);
|
||||
|
||||
f.install_owner("0.1.0").unwrap();
|
||||
let dump = f.run(OWNER, "dump", None).unwrap().unwrap();
|
||||
assert!(dump.contains("optional-one"), "contributed once the owner appeared: {dump:?}");
|
||||
|
||||
// Off and on again: it is back without restarting anything.
|
||||
f.host.set_enabled(OWNER, false).unwrap();
|
||||
assert_eq!(f.info("test.optional").status, PluginStatus::Static, "optional: not blocked");
|
||||
f.host.set_enabled(OWNER, true).unwrap();
|
||||
assert!(f.run(OWNER, "dump", None).unwrap().unwrap().contains("optional-one"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_owner_hears_that_contributions_changed() {
|
||||
let f = Fixture::new();
|
||||
f.install_owner("0.1.0").unwrap();
|
||||
// The owner is running when the contribution arrives.
|
||||
f.run(OWNER, "dump", None).unwrap();
|
||||
f.contributor().unwrap();
|
||||
f.host.flush_events();
|
||||
assert!(f.host.wait_events(Duration::from_secs(10)), "the event was delivered");
|
||||
let seen = f.run(OWNER, "changes", None).unwrap().unwrap();
|
||||
assert!(seen.contains(POINT), "{seen}");
|
||||
|
||||
// Only the owner hears: a plugin that declares nothing is not woken for it.
|
||||
let other = f.info("test.ext-contributor");
|
||||
assert_eq!(other.stats.activations, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dependent_follows_its_dependency_through_permission_changes() {
|
||||
let f = Fixture::new();
|
||||
f.install_owner("0.1.0").unwrap();
|
||||
f.contributor().unwrap();
|
||||
assert_eq!(f.info("test.ext-contributor").status, PluginStatus::Static);
|
||||
|
||||
// Revoking a permission the owner requires stops it, and what needs it with it.
|
||||
f.host.set_capability(OWNER, "storage", false).unwrap();
|
||||
assert_ne!(f.info(OWNER).status, PluginStatus::Active);
|
||||
assert_eq!(f.info("test.ext-contributor").status, PluginStatus::Blocked);
|
||||
|
||||
// Granting it again starts both, without touching the dependent.
|
||||
f.host.set_capability(OWNER, "storage", true).unwrap();
|
||||
assert_eq!(f.info("test.ext-contributor").status, PluginStatus::Static);
|
||||
assert!(f.run(OWNER, "dump", None).unwrap().unwrap().contains("from-contributor"));
|
||||
}
|
||||
@@ -98,6 +98,44 @@ impl PluginWorkspace for TestWorkspace {
|
||||
fn projects(&self) -> Vec<PathBuf> {
|
||||
std::iter::once(self.root.clone()).chain(self.other_roots.lock().iter().cloned()).collect()
|
||||
}
|
||||
/// Find in Files over the fixture's files on disk.
|
||||
fn search_text(
|
||||
&self,
|
||||
_client: Option<&str>,
|
||||
root: &Path,
|
||||
query: ide_plugin::TextQuery,
|
||||
limit: u32,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> Result<ide_plugin::TextSearch, HostError> {
|
||||
let query = ide_search::SearchQuery {
|
||||
pattern: query.pattern,
|
||||
is_regex: query.regex,
|
||||
case_sensitive: query.case_sensitive,
|
||||
whole_word: query.whole_word,
|
||||
include: query.include,
|
||||
exclude: query.exclude,
|
||||
scope: ide_search::SearchScope::Project,
|
||||
max_matches: limit,
|
||||
context: Default::default(),
|
||||
};
|
||||
let files = Mutex::new(Vec::new());
|
||||
let summary = ide_search::search(root, &query, &|_| None, cancel, &|file| files.lock().push(file))
|
||||
.map_err(|e| HostError::Failed(e.to_string()))?;
|
||||
let matches = files
|
||||
.into_inner()
|
||||
.into_iter()
|
||||
.flat_map(|file| {
|
||||
file.matches.into_iter().map(move |m| ide_plugin::TextMatch {
|
||||
location: ide_plugin::Location {
|
||||
path: PathBuf::from(&file.path.0),
|
||||
range: ide_core::Range::new(ide_core::Pos::new(m.line, 0), ide_core::Pos::new(m.line, 0)),
|
||||
},
|
||||
line: m.text,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(ide_plugin::TextSearch { matches, limit_hit: summary.limit_hit })
|
||||
}
|
||||
fn buffer_text(&self, path: &Path) -> Option<String> {
|
||||
if path.ends_with("event-barrier.txt") {
|
||||
let gate = self.event_read_gate.lock().take();
|
||||
@@ -451,6 +489,29 @@ fn read_file_is_confined_to_the_project() {
|
||||
assert_eq!(f.status("misbehave"), PluginStatus::Active);
|
||||
}
|
||||
|
||||
/// `project.read-bytes` returns the exact bytes on disk, where `read-file`
|
||||
/// wants UTF-8, and follows `read-file`'s confinement rules.
|
||||
#[test]
|
||||
fn read_bytes_reads_the_project_file_exactly_and_stays_inside() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&misbehave_pkg());
|
||||
let binary = [0x25u8, 0x50, 0x44, 0x46, 0x00, 0xff, 0x1b, 0x7f];
|
||||
std::fs::write(f.project.join("logo.bin"), binary).unwrap();
|
||||
let hex = binary.iter().map(|b| format!("{b:02x}")).collect::<Vec<_>>().join(" ");
|
||||
assert_eq!(f.run("misbehave", "read-bytes", Some("logo.bin")).unwrap().unwrap(), format!("8 {hex}"));
|
||||
let abs = f.project.join("logo.bin");
|
||||
assert_eq!(f.run("misbehave", "read-bytes", Some(abs.to_str().unwrap())).unwrap().unwrap(), format!("8 {hex}"));
|
||||
// The same refusal and not-found behaviour as `read-file`; like every
|
||||
// refusal, it is the plugin's to handle: it keeps running.
|
||||
let err = f.run("misbehave", "read-bytes", Some("../secret.txt")).unwrap_err();
|
||||
assert!(err.contains("outside the project root"), "{err}");
|
||||
let err = f.run("misbehave", "read-bytes", Some("src/missing.bin")).unwrap_err();
|
||||
assert!(err.contains("not found"), "{err}");
|
||||
let err = f.run("misbehave", "read-bytes", Some("src")).unwrap_err();
|
||||
assert!(err.contains("not a file"), "{err}");
|
||||
assert_eq!(f.status("misbehave"), PluginStatus::Active);
|
||||
}
|
||||
|
||||
/// `fs.read` reaches the granted paths and nothing else, through the real
|
||||
/// component: the same checks the runtime makes, against a real file system.
|
||||
#[test]
|
||||
@@ -2740,9 +2801,12 @@ fn official_mybatis_navigates_between_interface_and_xml() {
|
||||
let xml = "<mapper namespace=\"com.acme.UserMapper\">\n <select id=\"findById\">select 1</select>\n</mapper>\n";
|
||||
std::fs::write(&java_path, java).unwrap();
|
||||
std::fs::write(&xml_path, xml).unwrap();
|
||||
// Noise the index has to skip.
|
||||
for i in 0..200 {
|
||||
std::fs::write(f.project.join(format!("src/f{i}.xml")), "<beans/>").unwrap();
|
||||
// Noise: other mappers, some large, that the lookup must not read one by one.
|
||||
let filler = " <select id=\"q\">select 1</select>\n".repeat(3000);
|
||||
for i in 0..600 {
|
||||
let body = if i % 50 == 0 { filler.as_str() } else { "" };
|
||||
let other = format!("<mapper namespace=\"com.acme.Other{i}\">\n{body}</mapper>\n");
|
||||
std::fs::write(f.project.join(format!("src/f{i}.xml")), other).unwrap();
|
||||
}
|
||||
let doc = |path: &Path, language: &str, text: &str| ide_plugin::ProviderDocument {
|
||||
path: path.to_path_buf(),
|
||||
@@ -2751,9 +2815,9 @@ fn official_mybatis_navigates_between_interface_and_xml() {
|
||||
text: text.into(),
|
||||
};
|
||||
let jdoc = doc(&java_path, "java", java);
|
||||
// The first call indexes the XML files; on a slow machine that runs past the
|
||||
// edit-path budget, so the answer is dropped (the index is kept) and calls
|
||||
// queued behind it are skipped. The editor asks again; so does this.
|
||||
// The first call searches the XML files; on a slow machine that can run past the
|
||||
// edit-path budget, so the answer is dropped and calls queued behind it are
|
||||
// skipped. The editor asks again; so does this.
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let markers = loop {
|
||||
let markers = f.host.provide_line_markers(&jdoc);
|
||||
@@ -2782,7 +2846,7 @@ fn official_mybatis_navigates_between_interface_and_xml() {
|
||||
"not its language"
|
||||
);
|
||||
|
||||
// Warm (index cached): well inside the edit-path budget.
|
||||
// Warm (lookup cached): well inside the edit-path budget.
|
||||
let slow = f.host.info_of("openidea.mybatis").unwrap().stats.slow_calls;
|
||||
let mut times = Vec::new();
|
||||
for _ in 0..20 {
|
||||
@@ -5723,6 +5787,7 @@ fn api14_denies_new_privileges_and_requires_an_invoking_window() {
|
||||
("open-editors", "", "editor.read"),
|
||||
("open-editor", "a.txt", "project.read"),
|
||||
("search-text", "needle", "project.read"),
|
||||
("read-bytes", "a.txt", "project.read"),
|
||||
("problems", "", "project.read"),
|
||||
("decorations", "a.txt", "project.read"),
|
||||
("clear-decorations", "", "project.read"),
|
||||
|
||||
@@ -319,6 +319,19 @@ impl ProjectIndex {
|
||||
matching::symbols_named(&self.shared.data.read(), name, kind, limit)
|
||||
}
|
||||
|
||||
/// Symbols named exactly `name` (of `kind`, if given) whose file is inside one of `roots`
|
||||
/// (the project root plus its configured dependency roots): answers bounded by the cap
|
||||
/// never evict matches from a root because another root filled it first.
|
||||
pub fn symbols_named_within(
|
||||
&self,
|
||||
name: &str,
|
||||
kind: Option<SymbolKind>,
|
||||
roots: &[PathBuf],
|
||||
limit: usize,
|
||||
) -> Vec<SymbolMatch> {
|
||||
matching::symbols_named_within(&self.shared.data.read(), name, kind, roots, limit)
|
||||
}
|
||||
|
||||
/// Incrementally re-indexes changed paths (called from the vfs watcher).
|
||||
/// Cheap: the work is queued for the indexing thread.
|
||||
pub fn update(&self, changed: &[PathBuf], removed: &[PathBuf]) {
|
||||
|
||||
@@ -526,11 +526,26 @@ pub(crate) fn find_symbols_until(
|
||||
|
||||
/// Symbols named exactly `name` (and of `kind`, when given), by path then position.
|
||||
pub(crate) fn symbols_named(data: &IndexData, name: &str, kind: Option<SymbolKind>, limit: usize) -> Vec<SymbolMatch> {
|
||||
symbols_named_within(data, name, kind, &[], limit)
|
||||
}
|
||||
|
||||
/// Symbols named exactly `name` (and of `kind`, when given) whose file is inside one of
|
||||
/// `roots` (empty = every indexed file), by path then position. Filtering while walking
|
||||
/// entries keeps symbols outside the roots from materialising or filling the cap.
|
||||
pub(crate) fn symbols_named_within(
|
||||
data: &IndexData,
|
||||
name: &str,
|
||||
kind: Option<SymbolKind>,
|
||||
roots: &[PathBuf],
|
||||
limit: usize,
|
||||
) -> Vec<SymbolMatch> {
|
||||
let mask = char_mask(name);
|
||||
let within = |e: &FileEntry| roots.is_empty() || roots.iter().any(|root| e.path.starts_with(root));
|
||||
let mut out: Vec<SymbolMatch> = data
|
||||
.files
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|e| within(e))
|
||||
.flat_map(|e| {
|
||||
e.symbols.iter().filter(|s| s.mask == mask && &*s.name == name && kind.is_none_or(|k| s.kind == k)).map(
|
||||
move |s| SymbolMatch {
|
||||
|
||||
@@ -528,6 +528,27 @@ fn css_selectors_are_found_by_exact_name() {
|
||||
assert!(idx.symbols_named(".card", Some(SymbolKind::Field), 10).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbols_named_within_keeps_out_of_root_matches_from_evicting_a_root() {
|
||||
let f = Fixture::new(&[
|
||||
("a/w.rs", "struct Decoder;\nstruct Decoder;\n"),
|
||||
("b/x.rs", "struct Decoder;\nstruct Decoder;\nstruct Decoder;\n"),
|
||||
("dep/b.rs", "struct Decoder;\n"),
|
||||
]);
|
||||
let idx = f.open();
|
||||
let own = f.root.join("a");
|
||||
let dep = f.root.join("dep");
|
||||
// Unfiltered, the cap fills in rel-path order — own root, then the out-of-root
|
||||
// content root b/ — so dep/ is evicted before the caller's root filter runs.
|
||||
let unfiltered = idx.symbols_named("Decoder", None, 3);
|
||||
let unfiltered: Vec<&str> = unfiltered.iter().map(|m| m.rel_path.as_ref()).collect();
|
||||
assert_eq!(unfiltered, vec!["a/w.rs", "a/w.rs", "b/x.rs"]);
|
||||
let found: Vec<String> =
|
||||
idx.symbols_named_within("Decoder", None, &[own, dep], 3).into_iter().map(|m| m.rel_path).collect();
|
||||
assert_eq!(found, vec!["a/w.rs".to_string(), "a/w.rs".to_string(), "dep/b.rs".to_string()]);
|
||||
assert!(idx.symbols_named_within("Decoder", None, &[f.root.join("none")], 3).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_roots_and_excludes_change_without_reopening() {
|
||||
let fx = Fixture::new(&[("src/main.rs", "fn alpha() {}\n"), ("src/gen/out.rs", "fn generated() {}\n")]);
|
||||
|
||||
@@ -10,6 +10,10 @@ use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Pinned Compose Specification JSON Schema URL associated with `yaml-language-server`.
|
||||
pub const COMPOSE_SCHEMA_URL: &str =
|
||||
"https://raw.githubusercontent.com/compose-spec/compose-go/v2.16.2/schema/compose-spec.json";
|
||||
|
||||
mod markers;
|
||||
pub mod plugin_point;
|
||||
mod registry;
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
//! Docker Compose content detection and `yaml-language-server` schema association
|
||||
//! for open YAML documents whose filename does not already match `*compose*.y(a)ml`.
|
||||
|
||||
use ide_language::{COMPOSE_SCHEMA_URL, LanguageServerConfig};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const SERVER: &str = "yaml-language-server";
|
||||
|
||||
const TOP_SECTIONS: &[&str] = &["version", "name", "networks", "volumes", "configs", "secrets", "include"];
|
||||
|
||||
const SERVICE_KEYS: &[&str] = &[
|
||||
"image",
|
||||
"build",
|
||||
"container_name",
|
||||
"hostname",
|
||||
"domainname",
|
||||
"ports",
|
||||
"expose",
|
||||
"environment",
|
||||
"env_file",
|
||||
"volumes",
|
||||
"volumes_from",
|
||||
"networks",
|
||||
"network_mode",
|
||||
"depends_on",
|
||||
"command",
|
||||
"entrypoint",
|
||||
"restart",
|
||||
"pull_policy",
|
||||
"healthcheck",
|
||||
"deploy",
|
||||
"develop",
|
||||
"labels",
|
||||
"annotations",
|
||||
"platform",
|
||||
"profiles",
|
||||
"user",
|
||||
"working_dir",
|
||||
"privileged",
|
||||
"read_only",
|
||||
"stdin_open",
|
||||
"tty",
|
||||
"init",
|
||||
"cap_add",
|
||||
"cap_drop",
|
||||
"cgroup",
|
||||
"cgroup_parent",
|
||||
"configs",
|
||||
"secrets",
|
||||
"cpus",
|
||||
"cpu_count",
|
||||
"cpu_percent",
|
||||
"cpu_shares",
|
||||
"cpu_period",
|
||||
"cpu_quota",
|
||||
"cpuset",
|
||||
"devices",
|
||||
"dns",
|
||||
"dns_opt",
|
||||
"dns_search",
|
||||
"extends",
|
||||
"external_links",
|
||||
"extra_hosts",
|
||||
"group_add",
|
||||
"ipc",
|
||||
"isolation",
|
||||
"links",
|
||||
"logging",
|
||||
"mac_address",
|
||||
"mem_limit",
|
||||
"mem_reservation",
|
||||
"oom_kill_disable",
|
||||
"oom_score_adj",
|
||||
"pid",
|
||||
"pids_limit",
|
||||
"post_start",
|
||||
"pre_stop",
|
||||
"runtime",
|
||||
"scale",
|
||||
"security_opt",
|
||||
"shm_size",
|
||||
"stop_grace_period",
|
||||
"stop_signal",
|
||||
"storage_opt",
|
||||
"sysctls",
|
||||
"tmpfs",
|
||||
"ulimits",
|
||||
"userns_mode",
|
||||
"uts",
|
||||
];
|
||||
|
||||
fn parse_key(trimmed: &str) -> Option<&str> {
|
||||
let colon = trimmed.find(':')?;
|
||||
let after = &trimmed[colon + 1..];
|
||||
if !after.is_empty() && !after.starts_with(char::is_whitespace) {
|
||||
return None;
|
||||
}
|
||||
let key = trimmed[..colon].trim_end();
|
||||
let key = key
|
||||
.strip_prefix('"')
|
||||
.and_then(|s| s.strip_suffix('"'))
|
||||
.or_else(|| key.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
|
||||
.unwrap_or(key);
|
||||
(!key.is_empty() && key.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')))
|
||||
.then_some(key)
|
||||
}
|
||||
|
||||
/// Whether `path`'s filename already matches the Compose schema globs in `languages/yaml.toml`.
|
||||
pub fn is_named_compose_file(path: &Path) -> bool {
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else { return false };
|
||||
let Some(stem) = name.strip_suffix(".yaml").or_else(|| name.strip_suffix(".yml")) else {
|
||||
return false;
|
||||
};
|
||||
// `<head>` or `<head>.*`, where `<head>` is `compose`, `docker-compose`, `*-compose` or `*.compose`.
|
||||
let is_head = |head: &str| {
|
||||
head == "compose"
|
||||
|| head == "docker-compose"
|
||||
|| head.strip_suffix("-compose").is_some_and(|p| !p.is_empty())
|
||||
|| head.strip_suffix(".compose").is_some_and(|p| !p.is_empty())
|
||||
};
|
||||
is_head(stem) || stem.match_indices('.').any(|(i, _)| i + 1 < stem.len() && is_head(&stem[..i]))
|
||||
}
|
||||
|
||||
/// Whether `path` is a YAML file whose name does not match a Compose glob, but whose content is Compose.
|
||||
pub fn is_content_detected_compose_file(path: &Path, text: &str) -> bool {
|
||||
!is_named_compose_file(path) && is_compose_content(text)
|
||||
}
|
||||
|
||||
/// Whether `text` looks like a Docker Compose YAML configuration.
|
||||
pub fn is_compose_content(text: &str) -> bool {
|
||||
let mut has_services = false;
|
||||
let mut has_compose_top = false;
|
||||
let mut has_api_version = false;
|
||||
let mut has_kind = false;
|
||||
let mut has_on = false;
|
||||
let mut has_jobs = false;
|
||||
let mut in_services = false;
|
||||
let mut service_indent = None;
|
||||
let mut prop_indent = None;
|
||||
let mut has_service_prop = false;
|
||||
|
||||
for raw in text.lines().take(600) {
|
||||
let line = raw.trim_end_matches('\r');
|
||||
let trimmed = line.trim_start();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let lead = line.len() - trimmed.len();
|
||||
if lead == 0 {
|
||||
in_services = false;
|
||||
if let Some(key) = parse_key(trimmed) {
|
||||
if matches!(key, "openapi" | "swagger" | "AWSTemplateFormatVersion") {
|
||||
return false;
|
||||
}
|
||||
has_api_version |= key == "apiVersion";
|
||||
has_kind |= key == "kind";
|
||||
has_on |= key == "on";
|
||||
has_jobs |= key == "jobs";
|
||||
if (has_api_version && has_kind) || (has_on && has_jobs) {
|
||||
return false;
|
||||
}
|
||||
if key == "services" {
|
||||
has_services = true;
|
||||
in_services = true;
|
||||
service_indent = None;
|
||||
prop_indent = None;
|
||||
} else if TOP_SECTIONS.contains(&key) || key.starts_with("x-") {
|
||||
has_compose_top = true;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if in_services && !has_service_prop {
|
||||
let svc_indent = *service_indent.get_or_insert(lead);
|
||||
if lead == svc_indent {
|
||||
prop_indent = None;
|
||||
} else if lead > svc_indent {
|
||||
let p_indent = *prop_indent.get_or_insert(lead);
|
||||
if lead == p_indent
|
||||
&& let Some(prop) = parse_key(trimmed)
|
||||
&& (SERVICE_KEYS.contains(&prop) || prop.starts_with("x-"))
|
||||
{
|
||||
has_service_prop = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
has_services && (has_compose_top || has_service_prop)
|
||||
}
|
||||
|
||||
/// Appends open content-detected Compose files to `yaml-language-server`'s
|
||||
/// `yaml.schemas[COMPOSE_SCHEMA_URL]` unless excluded by user settings.
|
||||
pub fn configure_yaml_compose_schemas(cfg: &mut LanguageServerConfig, compose_files: &[PathBuf]) {
|
||||
if cfg.name != SERVER || compose_files.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut settings = cfg.settings.take().filter(Value::is_object).unwrap_or_else(|| json!({}));
|
||||
let schemas = settings
|
||||
.as_object_mut()
|
||||
.expect("object")
|
||||
.entry("yaml")
|
||||
.or_insert_with(|| json!({}))
|
||||
.as_object_mut()
|
||||
.and_then(|yaml| yaml.entry("schemas").or_insert_with(|| json!({})).as_object_mut());
|
||||
if let Some(schemas) = schemas {
|
||||
let entry = schemas.entry(COMPOSE_SCHEMA_URL).or_insert_with(|| json!([]));
|
||||
if let Value::String(single) = entry {
|
||||
*entry = json!([single.clone()]);
|
||||
}
|
||||
if let Some(list) = entry.as_array_mut() {
|
||||
for path in compose_files {
|
||||
let p = path.to_string_lossy().replace('\\', "/");
|
||||
let negated = format!("!{p}");
|
||||
if list.iter().any(|v| v.as_str() == Some(&p) || v.as_str() == Some(&negated)) {
|
||||
continue;
|
||||
}
|
||||
list.push(Value::String(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
cfg.settings = Some(settings);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_language::LanguageRegistry;
|
||||
|
||||
#[test]
|
||||
fn detects_compose_content_and_ignores_other_manifests() {
|
||||
assert!(is_compose_content("services:\n db:\n image: postgres:16\n"));
|
||||
assert!(is_compose_content("version: \"3.9\"\nservices:\n app:\n foo: bar\n"));
|
||||
assert!(!is_compose_content("apiVersion: v1\nkind: Service\nservices:\n db:\n image: x\n"));
|
||||
assert!(!is_compose_content("on: push\njobs:\n build:\n runs-on: ubuntu-latest\n"));
|
||||
assert!(is_named_compose_file(Path::new("/work/docker-compose.yml")));
|
||||
assert!(is_named_compose_file(Path::new("/work/playground-compose.yaml")));
|
||||
assert!(!is_named_compose_file(Path::new("/work/custom-stack.yml")));
|
||||
for named in
|
||||
["compose.yaml", "compose.override.yml", "docker-compose.prod.yaml", "a.compose.yml", "x-compose.ci.yml"]
|
||||
{
|
||||
assert!(is_named_compose_file(Path::new(named)), "{named}");
|
||||
}
|
||||
for unnamed in ["docker-compose-dev.yml", "compose-prod.yaml", "composer.yml", "decompose.yaml", "-compose.yml"]
|
||||
{
|
||||
assert!(!is_named_compose_file(Path::new(unnamed)), "{unnamed}");
|
||||
}
|
||||
assert!(is_content_detected_compose_file(
|
||||
Path::new("/work/docker-compose-dev.yml"),
|
||||
"services:\n db:\n image: postgres:16\n"
|
||||
));
|
||||
assert!(!is_content_detected_compose_file(
|
||||
Path::new("/work/docker-compose.yml"),
|
||||
"services:\n db:\n image: postgres:16\n"
|
||||
));
|
||||
assert!(is_content_detected_compose_file(
|
||||
Path::new("/work/custom-stack.yml"),
|
||||
"services:\n db:\n image: postgres:16\n"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merges_content_detected_files_into_yaml_schemas_unless_excluded() {
|
||||
let reg = LanguageRegistry::load(None).unwrap();
|
||||
let yaml = reg.get(&ide_core::LanguageId::new("yaml")).unwrap();
|
||||
let mut cfg = yaml.language_servers[0].clone();
|
||||
let a = PathBuf::from("/work/custom-stack.yml");
|
||||
let b = PathBuf::from("/work/excluded.yml");
|
||||
cfg.settings.as_mut().unwrap()["yaml"]["schemas"][COMPOSE_SCHEMA_URL]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(json!("!/work/excluded.yml"));
|
||||
|
||||
configure_yaml_compose_schemas(&mut cfg, &[a, b]);
|
||||
let list = cfg.settings.as_ref().unwrap()["yaml"]["schemas"][COMPOSE_SCHEMA_URL]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.collect::<Vec<_>>();
|
||||
assert!(list.contains(&"compose.yml"));
|
||||
assert!(list.contains(&"*-compose.yml"));
|
||||
assert!(list.contains(&"/work/custom-stack.yml"));
|
||||
assert!(!list.contains(&"/work/excluded.yml"));
|
||||
assert!(list.contains(&"!/work/excluded.yml"));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ mod angular;
|
||||
mod arduino;
|
||||
mod caps;
|
||||
mod clang_tidy;
|
||||
mod compose;
|
||||
mod convert;
|
||||
mod external_project;
|
||||
mod file_ops;
|
||||
@@ -107,6 +108,8 @@ struct DocState {
|
||||
version: i32,
|
||||
text: Arc<DocText>,
|
||||
servers: Vec<Attached>,
|
||||
/// Open YAML document whose filename does not match a Compose glob and whose content is Compose.
|
||||
compose_detected: bool,
|
||||
}
|
||||
|
||||
impl DocState {
|
||||
@@ -1017,6 +1020,7 @@ impl LspManager {
|
||||
servers: Vec<Arc<Server>>,
|
||||
) {
|
||||
let canonical = dunce::canonicalize(&path).unwrap_or_else(|_| path.clone());
|
||||
let compose_detected = language.as_str() == "yaml" && compose::is_content_detected_compose_file(&path, text);
|
||||
let mut state = DocState {
|
||||
path,
|
||||
canonical,
|
||||
@@ -1025,9 +1029,12 @@ impl LspManager {
|
||||
version: version as i32,
|
||||
text: Arc::new(DocText::new(text)),
|
||||
servers: servers.iter().map(|s| Attached { server: s.clone(), opened_gen: None }).collect(),
|
||||
compose_detected,
|
||||
};
|
||||
{
|
||||
let compose_added = {
|
||||
let mut docs = self.docs.lock();
|
||||
let already_detected =
|
||||
compose_detected && docs.values().any(|d| d.compose_detected && d.path == state.path);
|
||||
let params = state.did_open_params();
|
||||
for a in &mut state.servers {
|
||||
if let Some(run) = a.server.running() {
|
||||
@@ -1038,13 +1045,17 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
docs.insert(doc, state);
|
||||
}
|
||||
compose_detected && !already_detected
|
||||
};
|
||||
self.schedule_pull(doc, None, Duration::ZERO);
|
||||
for s in &servers {
|
||||
self.ensure_started(s);
|
||||
if s.running().is_some()
|
||||
&& let Some(me) = self.me.upgrade()
|
||||
{
|
||||
if s.name == compose::SERVER && compose_added {
|
||||
me.reconfigure_soon();
|
||||
}
|
||||
let server = s.clone();
|
||||
self.runtime.spawn(async move { me.check_clang_tidy(&server).await });
|
||||
}
|
||||
@@ -1115,7 +1126,18 @@ impl LspManager {
|
||||
tracing::debug!("{}: didChange: {e}", server.name);
|
||||
}
|
||||
}
|
||||
let compose_changed = if d.language.as_str() == "yaml" && !compose::is_named_compose_file(&d.path) {
|
||||
let now = compose::is_compose_content(text.as_str());
|
||||
let changed = std::mem::replace(&mut d.compose_detected, now) != now;
|
||||
let closed_path = d.path.clone();
|
||||
changed && !docs.iter().any(|(id, other)| *id != doc && other.compose_detected && other.path == closed_path)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
drop(docs);
|
||||
if compose_changed && let Some(me) = self.me.upgrade() {
|
||||
me.reconfigure_soon();
|
||||
}
|
||||
self.schedule_pull(doc, None, pull::AFTER_EDIT);
|
||||
self.invalidate_injections(doc);
|
||||
self.schedule_injections(doc);
|
||||
@@ -1175,7 +1197,16 @@ impl LspManager {
|
||||
}
|
||||
|
||||
fn close_document(&self, doc: DocId) {
|
||||
let Some(d) = self.docs.lock().remove(&doc) else { return };
|
||||
let (d, compose_removed) = {
|
||||
let mut docs = self.docs.lock();
|
||||
let Some(d) = docs.remove(&doc) else { return };
|
||||
let removed =
|
||||
d.compose_detected && !docs.values().any(|other| other.compose_detected && other.path == d.path);
|
||||
(d, removed)
|
||||
};
|
||||
if compose_removed && let Some(me) = self.me.upgrade() {
|
||||
me.reconfigure_soon();
|
||||
}
|
||||
for a in &d.servers {
|
||||
self.forget_pulled(&a.server, &d.path, a.server.running().as_ref());
|
||||
let Some(run) = a.server.running() else { continue };
|
||||
@@ -3169,9 +3200,20 @@ impl LspManager {
|
||||
if let Some(hook) = self.config_hook.read().clone() {
|
||||
hook(&mut cfg, &server.root);
|
||||
}
|
||||
if cfg.name == compose::SERVER {
|
||||
compose::configure_yaml_compose_schemas(&mut cfg, &self.open_compose_files());
|
||||
}
|
||||
cfg
|
||||
}
|
||||
|
||||
fn open_compose_files(&self) -> Vec<PathBuf> {
|
||||
let mut files: Vec<PathBuf> =
|
||||
self.docs.lock().values().filter(|d| d.compose_detected).map(|d| d.path.clone()).collect();
|
||||
files.sort();
|
||||
files.dedup();
|
||||
files
|
||||
}
|
||||
|
||||
/// Restarts running servers whose settings-derived configuration changed
|
||||
/// (e.g. a new project interpreter); stopped ones pick it up on their next start.
|
||||
/// A change of `settings` alone reaches a running server without a restart.
|
||||
|
||||
@@ -104,6 +104,12 @@ pub trait HostApi: Send + Sync {
|
||||
let _ = client;
|
||||
None
|
||||
}
|
||||
/// What other plugins contributed to `point`, as (plugin id, JSON). Only the
|
||||
/// plugin that declares the point may read it.
|
||||
fn extension_contributions(&self, point: &str) -> Result<Vec<(String, String)>, HostError> {
|
||||
let _ = point;
|
||||
Err(HostError::NotFound("extension points are unavailable on this host".into()))
|
||||
}
|
||||
|
||||
/// Registers a project-scoped wrapper after validating its process grant and pin.
|
||||
fn register_run_target(&self, root: &Path, target: crate::PluginRunTarget) -> Result<(), HostError> {
|
||||
@@ -994,6 +1000,10 @@ fn event_to_wit(e: WorkbenchEvent) -> wit_events::Event {
|
||||
kind: "settings-changed".into(),
|
||||
data: serde_json::to_string(&keys).expect("keys serialize"),
|
||||
}),
|
||||
WorkbenchEvent::ExtensionsChanged(points) => wit_events::Event::Other(wit_types::Extension {
|
||||
kind: "extensions-changed".into(),
|
||||
data: serde_json::to_string(&points).expect("points 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"),
|
||||
|
||||
@@ -169,6 +169,19 @@ pub fn read_text(real: &Path) -> Result<String, String> {
|
||||
String::from_utf8(bytes).map_err(|_| format!("{}: not valid UTF-8 text", real.display()))
|
||||
}
|
||||
|
||||
/// Reads the bytes of a file that `resolve_in_root` already approved: binary
|
||||
/// content is fine, the size cap is the same as [`read_text`]'s.
|
||||
pub fn read_bytes(real: &Path) -> Result<Vec<u8>, String> {
|
||||
let meta = std::fs::metadata(real).map_err(|e| e.to_string())?;
|
||||
if !meta.is_file() {
|
||||
return Err(format!("{}: not a file", real.display()));
|
||||
}
|
||||
if meta.len() > MAX_READ_BYTES {
|
||||
return Err(format!("{}: file is larger than {} MiB", real.display(), MAX_READ_BYTES >> 20));
|
||||
}
|
||||
std::fs::read(real).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Why a read outside the project was refused.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Refusal {
|
||||
@@ -528,6 +541,25 @@ mod tests {
|
||||
assert!(read_text(&p).unwrap_err().contains("UTF-8"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bytes_returns_the_exact_bytes_up_to_the_cap() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// The bytes binary files carry survive, where read_text refuses them.
|
||||
let binary = [0x25, 0x50, 0x44, 0x46, 0x00, 0xff, 0xfe, 0x1b];
|
||||
std::fs::write(tmp.path().join("b.bin"), binary).unwrap();
|
||||
let p = resolve_in_root(tmp.path(), "b.bin").unwrap();
|
||||
assert_eq!(read_bytes(&p).unwrap(), binary);
|
||||
// A directory is not a file; the size cap is read_text's.
|
||||
assert!(read_bytes(&resolve_in_root(tmp.path(), ".").unwrap()).unwrap_err().contains("not a file"));
|
||||
let big = tmp.path().join("big.bin");
|
||||
std::fs::write(&big, vec![0u8; (MAX_READ_BYTES + 1) as usize]).unwrap();
|
||||
let err = read_bytes(&big).unwrap_err();
|
||||
assert!(err.contains("larger than"), "{err}");
|
||||
let at_cap = tmp.path().join("at-cap.bin");
|
||||
std::fs::write(&at_cap, vec![7u8; MAX_READ_BYTES as usize]).unwrap();
|
||||
assert_eq!(read_bytes(&at_cap).unwrap().len(), MAX_READ_BYTES as usize);
|
||||
}
|
||||
|
||||
/// The grant a manifest author would write for `dir`: the plain path, no
|
||||
/// `\\?\` prefix.
|
||||
fn grant_for(dir: &Path) -> Capability {
|
||||
|
||||
@@ -110,6 +110,7 @@ pub enum EventKind {
|
||||
RunExited,
|
||||
TerminalExited,
|
||||
ContentTabAction,
|
||||
ExtensionsChanged,
|
||||
FileOpened,
|
||||
FileSaved,
|
||||
FilesChanged,
|
||||
@@ -120,13 +121,14 @@ pub enum EventKind {
|
||||
}
|
||||
|
||||
impl EventKind {
|
||||
pub const ALL: [Self; 13] = [
|
||||
pub const ALL: [Self; 14] = [
|
||||
Self::FileClosed,
|
||||
Self::SettingsChanged,
|
||||
Self::InputModeChanged,
|
||||
Self::RunExited,
|
||||
Self::TerminalExited,
|
||||
Self::ContentTabAction,
|
||||
Self::ExtensionsChanged,
|
||||
Self::FileOpened,
|
||||
Self::FileSaved,
|
||||
Self::FilesChanged,
|
||||
@@ -144,6 +146,7 @@ impl EventKind {
|
||||
Self::RunExited => "run-exited",
|
||||
Self::TerminalExited => "terminal-exited",
|
||||
Self::ContentTabAction => "content-tab-action",
|
||||
Self::ExtensionsChanged => "extensions-changed",
|
||||
Self::FileOpened => "file-opened",
|
||||
Self::FileSaved => "file-saved",
|
||||
Self::FilesChanged => "files-changed",
|
||||
@@ -163,7 +166,11 @@ impl EventKind {
|
||||
pub fn capability(self) -> Option<ide_extension::Capability> {
|
||||
match self {
|
||||
Self::ActionPerformed | Self::InputModeChanged => Some(ide_extension::Capability::InputEvents),
|
||||
Self::Scheduled | Self::RunExited | Self::TerminalExited | Self::ContentTabAction => None,
|
||||
Self::Scheduled
|
||||
| Self::RunExited
|
||||
| Self::TerminalExited
|
||||
| Self::ContentTabAction
|
||||
| Self::ExtensionsChanged => None,
|
||||
_ => Some(ide_extension::Capability::Events),
|
||||
}
|
||||
}
|
||||
@@ -229,6 +236,8 @@ pub enum WorkbenchEvent {
|
||||
ContentTabAction(crate::ContentTabAction),
|
||||
FileClosed(String),
|
||||
SettingsChanged(Vec<String>),
|
||||
/// Ids of extension points the receiving plugin declares whose contributions changed.
|
||||
ExtensionsChanged(Vec<String>),
|
||||
InputModeChanged(InputMode),
|
||||
FileOpened(String),
|
||||
FileSaved(String),
|
||||
@@ -247,6 +256,7 @@ impl WorkbenchEvent {
|
||||
Self::ContentTabAction(_) => EventKind::ContentTabAction,
|
||||
Self::FileClosed(_) => EventKind::FileClosed,
|
||||
Self::SettingsChanged(_) => EventKind::SettingsChanged,
|
||||
Self::ExtensionsChanged(_) => EventKind::ExtensionsChanged,
|
||||
Self::InputModeChanged(_) => EventKind::InputModeChanged,
|
||||
Self::FileOpened(_) => EventKind::FileOpened,
|
||||
Self::FileSaved(_) => EventKind::FileSaved,
|
||||
|
||||
@@ -64,6 +64,7 @@ pub(crate) mod syntax_bindings {
|
||||
"openidea:plugin/settings": super::bindings::openidea::plugin::settings,
|
||||
"openidea:plugin/database": super::bindings::openidea::plugin::database,
|
||||
"openidea:plugin/mcp": super::bindings::openidea::plugin::mcp,
|
||||
"openidea:plugin/extensions": super::bindings::openidea::plugin::extensions,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -101,6 +102,7 @@ pub(crate) mod providers_bindings {
|
||||
"openidea:plugin/settings": super::bindings::openidea::plugin::settings,
|
||||
"openidea:plugin/database": super::bindings::openidea::plugin::database,
|
||||
"openidea:plugin/mcp": super::bindings::openidea::plugin::mcp,
|
||||
"openidea:plugin/extensions": super::bindings::openidea::plugin::extensions,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -138,6 +140,7 @@ pub(crate) mod views_bindings {
|
||||
"openidea:plugin/settings": super::bindings::openidea::plugin::settings,
|
||||
"openidea:plugin/database": super::bindings::openidea::plugin::database,
|
||||
"openidea:plugin/mcp": super::bindings::openidea::plugin::mcp,
|
||||
"openidea:plugin/extensions": super::bindings::openidea::plugin::extensions,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -696,6 +699,22 @@ impl workspace::HostProject for StoreData {
|
||||
Ok(sandbox::read_text(&real).map_err(wit::Error::Failed))
|
||||
}
|
||||
|
||||
fn read_bytes(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<Vec<u8>> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let root = self.table.get(&this)?.root.clone();
|
||||
let real = match sandbox::resolve_in_root(&root, &path) {
|
||||
Ok(real) => real,
|
||||
Err(e) => {
|
||||
let e = if e.ends_with("no such file") { wit::Error::NotFound(e) } else { wit::Error::Invalid(e) };
|
||||
return Ok(Err(e));
|
||||
}
|
||||
};
|
||||
// The bytes on disk: an unsaved text buffer does not apply, binary files are the point.
|
||||
Ok(sandbox::read_bytes(&real).map_err(wit::Error::Failed))
|
||||
}
|
||||
|
||||
fn exists(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<bool> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
@@ -1146,6 +1165,11 @@ impl events::Host for StoreData {
|
||||
{
|
||||
return Ok(Err(to_wit(e)));
|
||||
}
|
||||
if kind == EventKind::ExtensionsChanged
|
||||
&& let Err(e) = self.host.require_api(kind.name(), 5)
|
||||
{
|
||||
return Ok(Err(to_wit(e)));
|
||||
}
|
||||
if let Some(cap) = kind.capability()
|
||||
&& let Err(e) = self.require(cap)
|
||||
{
|
||||
@@ -2356,6 +2380,19 @@ impl bindings::openidea::plugin::database::Host for StoreData {
|
||||
Ok(result.map_err(to_wit))
|
||||
}
|
||||
}
|
||||
impl bindings::openidea::plugin::extensions::Host for StoreData {
|
||||
fn contributions(&mut self, point: String) -> Fallible<Vec<bindings::openidea::plugin::extensions::Contribution>> {
|
||||
Ok(self
|
||||
.host
|
||||
.extension_contributions(&clip(point, 128))
|
||||
.map(|list| {
|
||||
list.into_iter()
|
||||
.map(|(plugin, data)| bindings::openidea::plugin::extensions::Contribution { plugin, data })
|
||||
.collect()
|
||||
})
|
||||
.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)))
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
163429536090fe11286cce425fabad3e84c14e1ea4e4b678be88a0bad0a1285a
|
||||
eb7d608bf1e7379620ef7d9ce5289dd9b927f137af2aafdd0861a84d60d677a2
|
||||
@@ -104,6 +104,10 @@ title = "reveal"
|
||||
id = "search-text"
|
||||
title = "search-text"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "read-bytes"
|
||||
title = "read-bytes"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "problems"
|
||||
title = "problems"
|
||||
|
||||
@@ -25,6 +25,7 @@ impl Plugin for Api14 {
|
||||
.search_text(&openidea_plugin::TextQuery { pattern: arg.into(), ..Default::default() }, 10)?;
|
||||
Ok(Some(format!("{result:?}")))
|
||||
}
|
||||
"read-bytes" => Ok(Some(format!("{:?}", ctx.project().unwrap().read_bytes(arg)?))),
|
||||
"problems" => Ok(Some(format!("{:?}", host::problems(if arg.is_empty() { None } else { Some(arg) })?))),
|
||||
"decorations" => {
|
||||
host::set_decorations(
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
bfdd78a242472a1279e0e393f66f0d96a856338e103aad454239a451df22cd05
|
||||
195cb4025508e7af6e6fb63d81660c4a977327fa3d101a2a77e5f448f43518f8
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
37276095967174307db4c5113d85b777138f60ee82d26b4af8ef3f2acd90aafa
|
||||
49491ad2d57592cca2451b0abde5f88a3aedd3f5d36ceaf8259c3254f59c5023
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
b9597de10e7182e1ed488f80b6c1deb9f810912797b2bf02c9cae0347ee38bc8
|
||||
b24383f6e05ec531ef8db368b79af710433bc3782438f5a9e3578ebb730a1ce2
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
3ee642e6d1f983ba5e186007e2115b0a104383510cc8acc13f3e0a83fe1ca5b6
|
||||
ef01f199e668063b2c444e4ccf169d5b5407e567dbe8ff583cfdfc111b2438cf
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
43f7af5d824853a3fbaa61ef408127cd2aac0134e6ec02ca2fb35813db4b0bd9
|
||||
a4dbe2a261e00809b64039ce9c89db681b186788db8a92b7456eca84b33c5945
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
d85df5d6fb5e9d746fe4b28e1f8546c30fe6dc53c1b9dfd9201bc6a9dacf2306
|
||||
3c9db6ffeaa9b782970c7a7a132878f862b0b5b18e9d11ad56649ee28317e3ca
|
||||
+336
@@ -0,0 +1,336 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3bd1574e617a9a6c1f8f8181bd7e841e1f20e4277607ef28945765188d6284d4"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-test-ext-owner"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ae9ccbea3a7b0b09443036490c8f34a8b1b760fd345e5c0dae0aac718faea79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"macro-string",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-ident",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "openidea-test-ext-owner"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
ac1dceb016b7963f44e876784ea62f776e90dd63fc6f660a5dad3dfbd7433b1c
|
||||
@@ -0,0 +1,27 @@
|
||||
id = "test.ext-owner"
|
||||
name = "Extension point owner"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "^1.5"
|
||||
wasm = "dist/plugin.wasm"
|
||||
activation = ["on-command:*", "on-event:extensions-changed"]
|
||||
|
||||
[capabilities]
|
||||
required = ["storage"]
|
||||
|
||||
[[extension-points]]
|
||||
id = "test.ext-owner.items"
|
||||
description = "Things other plugins add"
|
||||
required = ["name"]
|
||||
fields = { name = "string", weight = "integer" }
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "dump"
|
||||
title = "Dump contributions"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "read"
|
||||
title = "Read the contributions to any extension point"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "changes"
|
||||
title = "Show the change events received"
|
||||
@@ -0,0 +1,36 @@
|
||||
use openidea_plugin::{Error, Event, Invocation, Plugin, host, register_plugin};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Owner;
|
||||
|
||||
impl Plugin for Owner {
|
||||
fn run_command(&mut self, id: &str, _ctx: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> {
|
||||
match id {
|
||||
"dump" => {
|
||||
let list = host::extension_contributions("test.ext-owner.items")?;
|
||||
Ok(Some(list.iter().map(|c| format!("{}:{}", c.plugin, c.data)).collect::<Vec<_>>().join("\n")))
|
||||
}
|
||||
"read" => {
|
||||
let list = host::extension_contributions(arg.unwrap_or_default())?;
|
||||
Ok(Some(list.len().to_string()))
|
||||
}
|
||||
"changes" => host::storage::get("changes"),
|
||||
_ => Err(Error::NotFound(id.into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_events(&mut self, events: &[Event], _ctx: &Invocation) {
|
||||
let mut seen = host::storage::get("changes").unwrap().unwrap_or_default();
|
||||
for event in events {
|
||||
if let Event::Other { kind, data } = event
|
||||
&& kind == "extensions-changed"
|
||||
{
|
||||
seen.push_str(data);
|
||||
seen.push('\n');
|
||||
}
|
||||
}
|
||||
host::storage::set("changes", &seen).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
register_plugin!(Owner);
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
17fcb4251b6bdd055e4e856bd8d69d7eca52693cefb818b547787907022b7943
|
||||
11dc1a74c0650b493ed6ec88b5d15e08421ab08c5f2320099dabd5df1bdc5096
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
50d8a7304714e0810da7f8a2f723155287c26807848049b00492cb9def6c7763
|
||||
94c391142af832a67c694968d307217aabd7c139ae29e3299930b56ce644e45b
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
8442bac7628c5e3ddbf2af2337781016197028054c83d4589dbbcf479c77ff87
|
||||
e2c1893a160a0f27af7eb9e689d703577335cce32f746713c2435ae70fb9753d
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
054bdb7b732f74b0266880bfea3699bb9994bbdba4054dc2935a6cc0bcdf57de
|
||||
83a8f59e0bde75c5c8fc725aa0fe34954279b31b7237acc868470ccf545ca229
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
fb3097d0429782d24ac647433bb7146a29f9b28a505bd2e7693c0244dac3c1e1
|
||||
f9741cc8f7250a71311264613d636c5c844f1ae294362d4d331015ca73acbc57
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
e6da5f8b4c30861c17c7da1b62992de6132ed6ab99f404292193e39a40a68637
|
||||
a6f65bb0d13527d726cef88fa50a5db19d48517b1ae516db36e9756dc87e75bc
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
514f87032622056e8b89f1c4d7950bb0a466c9c857fe27d65a523ea7d3c35911
|
||||
c9dbae18196b98eb4fad86fd5f36033c2b82a3087b4f51c4cba5a1362924d795
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
8908a092da5958e55fab506e081695fc7b48ea3bf287832aaac3e27e4c2adb21
|
||||
1086cdc27def1958e8ec818e197af67edc2fd252b198e0ac358b149e0f5abfe8
|
||||
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
2a4996cbd60e29f4c3c47748d9ac7a4ca05ff4d8c55519fb6e06cec88b868be5
|
||||
48fe38b954f91bf8adc01a4b7b41802040f663c16fde586bd5daa51be98ba51d
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
4385c1553a4aaed9030d462a72677d07b02eaa0d08b8c1770f42116efca95d94
|
||||
9ff2eaaa054ecdab56bf01220c8529da2b8d074b923cfb8b332d347767ba04f4
|
||||
@@ -66,6 +66,13 @@ impl commands::Guest for Misbehave {
|
||||
let project = ctx.project().ok_or_else(|| failed("no project is open"))?;
|
||||
project.read_file(&arg.unwrap_or_default()).map(Some)
|
||||
}
|
||||
// The bytes `project.read-bytes` returns, as "len hex hex …" so binary survives.
|
||||
"read-bytes" => {
|
||||
let project = ctx.project().ok_or_else(|| failed("no project is open"))?;
|
||||
let bytes = project.read_bytes(&arg.unwrap_or_default())?;
|
||||
let hex: Vec<String> = bytes.iter().map(|b| format!("{b:02x}")).collect();
|
||||
Ok(Some(format!("{} {}", bytes.len(), hex.join(" "))))
|
||||
}
|
||||
"root" => ctx.project().map(|p| p.root()).transpose(),
|
||||
// The `fs.read` grants: the text of `arg`, or the refusal naming what it needs.
|
||||
"fsread" => openidea::plugin::fs::read_file(&arg.unwrap_or_default()).map(Some),
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
777d722fbe5673ca37165c2032389ea7c9518f7b0b3b51953e26ba01b50b3ec4
|
||||
d72afe7db6283cdaecd7c673d1d26ff5af39f105733109ec73901ca37b00ea28
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
e9b3cc0b1bf82b06ace741ccb9f9e23d0dab2f61a5695d786dec08b7839f77aa
|
||||
bccf0174932e661c40f05d779bf094e7aa4c91c359399688e68dd7a0a1d389b6
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
fac47c0d2e74cb236b063e8edc617a991aaf7eecf6cf71a75404310ed270f75e
|
||||
77909d57469273d0abd56d382e408ebb9a6c5dd4463b0c2cbca8f27a19bf116c
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
ba6c1563853158291b0524146031363c45288757cf959cf143db60437beb4b93
|
||||
79beb3b8fdb9f412132dda4a76b88f55cfa859e3462c8cb020a0e431ad23fd72
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
57e4765ec72da0bb4768b5c097fead43e9484449abfc80c925bccd7ecc91af7e
|
||||
f2430f9afa0c8af35d96832a0cf7b1eade8e5977fb46eed93123b65f950f4f15
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
6877c01f91c1ec0dfee326b6825d0d3fbbb14a67837eb7fd0eba10a727535b5b
|
||||
65753d7c4938560bf2cbc2d1eb2d5588f455ae40f265369aa1126c4f1fd0e5ce
|
||||
Loaded 100 of 378 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user