mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 17:22:55 -10:00
Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97abcc4dc8 | ||
|
|
dc69c36604 | ||
|
|
1891119f9d | ||
|
|
772d45dce2 | ||
|
|
8509ca671f | ||
|
|
ebc1c21836 | ||
|
|
429b8f0efe | ||
|
|
cf1397b425 | ||
|
|
8b8072b79a | ||
|
|
257fb0f50f | ||
|
|
668206ddfd | ||
|
|
4ef4adf9b3 | ||
|
|
4f71920960 | ||
|
|
20acda0f31 | ||
|
|
d509ed6e81 | ||
|
|
112ed773fb | ||
|
|
5890e8abc5 | ||
|
|
388ea0a198 | ||
|
|
f3837e0fe7 | ||
|
|
356c62f930 | ||
|
|
abc7be9d8d | ||
|
|
be0a6b4359 | ||
|
|
c720024b42 | ||
|
|
90cca59462 | ||
|
|
f96ca468a8 | ||
|
|
25d930b780 | ||
|
|
1ab4edc0d7 | ||
|
|
309bb48f2c | ||
|
|
75773dd889 | ||
|
|
1cab24fea1 | ||
|
|
bbbdcb465e | ||
|
|
e367f6757e | ||
|
|
619daa1489 | ||
|
|
2945d20208 | ||
|
|
e68b35f534 | ||
|
|
adc308eb99 | ||
|
|
e0408cc7d7 | ||
|
|
56796fb2c6 | ||
|
|
c614355753 | ||
|
|
7a9c72ca24 | ||
|
|
b23b95ec00 | ||
|
|
17a121e7f5 | ||
|
|
303d5950bf | ||
|
|
4cfdb972eb | ||
|
|
d235c432ec | ||
|
|
d3ea6f6daf |
No files matched your search
Generated
+19
-53
@@ -4168,6 +4168,20 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-cli"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
"winreg 0.56.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-clipboard"
|
||||
version = "0.1.38"
|
||||
@@ -4319,6 +4333,7 @@ name = "ide-host"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
"criterion",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
@@ -4675,7 +4690,6 @@ dependencies = [
|
||||
"keyring-core",
|
||||
"parking_lot",
|
||||
"russh",
|
||||
"russh-config",
|
||||
"russh-sftp",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5425,10 +5439,7 @@ version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"libc",
|
||||
"plain",
|
||||
"redox_syscall 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6370,6 +6381,7 @@ dependencies = [
|
||||
"ide-analysis",
|
||||
"ide-android",
|
||||
"ide-build",
|
||||
"ide-cli",
|
||||
"ide-clipboard",
|
||||
"ide-core",
|
||||
"ide-coverage",
|
||||
@@ -6603,7 +6615,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall 0.5.18",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
@@ -6773,12 +6785,6 @@ version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[package]]
|
||||
name = "plain"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "plist"
|
||||
version = "1.10.2"
|
||||
@@ -7236,15 +7242,6 @@ dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.3"
|
||||
@@ -7529,20 +7526,6 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "russh-config"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "993346101d4741a59c82bafc6b645c52ce3e7b0820f13e4b5d48cdb591e3d955"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"globset",
|
||||
"log",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"whoami 1.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "russh-cryptovec"
|
||||
version = "0.62.0"
|
||||
@@ -8360,7 +8343,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-quartz-core 0.3.2",
|
||||
"raw-window-handle",
|
||||
"redox_syscall 0.5.18",
|
||||
"redox_syscall",
|
||||
"tracing",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -8624,7 +8607,7 @@ dependencies = [
|
||||
"stringprep",
|
||||
"thiserror 2.0.21",
|
||||
"tracing",
|
||||
"whoami 2.1.3",
|
||||
"whoami",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10566,12 +10549,6 @@ dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.129"
|
||||
@@ -11146,17 +11123,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "whoami"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
|
||||
dependencies = [
|
||||
"libredox",
|
||||
"wasite",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "whoami"
|
||||
version = "2.1.3"
|
||||
|
||||
@@ -45,6 +45,7 @@ ide-host = { path = "crates/ide-host" }
|
||||
ide-notebook = { path = "crates/ide-notebook" }
|
||||
ide-extension = { path = "crates/ide-extension" }
|
||||
ide-plugin = { path = "crates/ide-plugin" }
|
||||
ide-cli = { path = "crates/ide-cli" }
|
||||
|
||||
# serialization & IPC typing
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -79,5 +79,6 @@ the [official plugins](plugins/official). The plugin API is still a preview.
|
||||
Database tool window notes, including Oracle Instant Client setup, are in
|
||||
[`docs/databases.md`](docs/databases.md).
|
||||
Java language server choices and their capabilities are in [`docs/java.md`](docs/java.md).
|
||||
Command-line launcher setup for macOS, Windows and Linux is in [`docs/command-line.md`](docs/command-line.md).
|
||||
|
||||
<sub>Not affiliated with JetBrains. IntelliJ IDEA is a trademark of JetBrains s.r.o.</sub>
|
||||
@@ -221,6 +221,7 @@ struct TaskState {
|
||||
/// A running agent and its session.
|
||||
#[derive(Clone)]
|
||||
struct Live {
|
||||
sign_in_message: String,
|
||||
peer: acp::Conn,
|
||||
session: String,
|
||||
capabilities: AgentCapabilities,
|
||||
@@ -1301,11 +1302,7 @@ impl Task {
|
||||
match e {
|
||||
// Signed out, or the account was refused (Claude Code: "API Error: 403").
|
||||
RpcError::Server { message, .. } if message.to_lowercase().contains("auth") => {
|
||||
let provider = self.state.lock().task.provider.clone();
|
||||
Failure::from(match self.shared.spec(&provider) {
|
||||
Some(spec) => format!("{} needs you to sign in. {}", spec.name, spec.login_hint),
|
||||
None => message,
|
||||
})
|
||||
Failure::from(self.live.lock().as_ref().map(|live| live.sign_in_message.clone()).unwrap_or(message))
|
||||
}
|
||||
e => Failure::from(self.describe(e)),
|
||||
}
|
||||
@@ -1323,6 +1320,11 @@ impl Task {
|
||||
(s.task.provider.clone(), s.task.cwd.0.clone(), s.session_id.clone())
|
||||
};
|
||||
let spec = self.shared.spec(&provider_id).ok_or_else(|| format!("unknown agent {provider_id}"))?;
|
||||
let launch_cwd = cwd.clone();
|
||||
let spec = tokio::task::spawn_blocking(move || spec.resolve_for_launch(&launch_cwd))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let sign_in_message = spec.sign_in_message();
|
||||
self.stderr.lock().clear();
|
||||
self.config_sent.lock().clear();
|
||||
let weak = Arc::downgrade(self);
|
||||
@@ -1368,7 +1370,7 @@ impl Task {
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
RpcError::Server { ref message, .. } if message.to_lowercase().contains("auth") => {
|
||||
format!("{} needs you to sign in. {}", spec.name, spec.login_hint)
|
||||
sign_in_message.clone()
|
||||
}
|
||||
e => self.describe(e),
|
||||
})?;
|
||||
@@ -1440,7 +1442,7 @@ impl Task {
|
||||
}
|
||||
self.persist();
|
||||
self.emit_task();
|
||||
let live = Live { peer, session: session_id, capabilities, embedded, resumable: can_load };
|
||||
let live = Live { sign_in_message, peer, session: session_id, capabilities, embedded, resumable: can_load };
|
||||
*self.live.lock() = Some(live.clone());
|
||||
Ok(live)
|
||||
}
|
||||
|
||||
@@ -81,6 +81,64 @@ fn builtins() -> Vec<ProviderSpec> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl ProviderSpec {
|
||||
pub(crate) fn login_hint(&self, program: &Path) -> String {
|
||||
if !program.is_absolute() {
|
||||
return self.login_hint.clone();
|
||||
}
|
||||
let search = ide_core::process::host_var_os("PATH").unwrap_or_default();
|
||||
let builtin = builtin_spec(&self.id);
|
||||
let on_path = builtin.as_ref().and_then(|b| ide_core::process::find_executable(&b.command, &search));
|
||||
render_login_hint(self, program, on_path.as_deref(), cfg!(windows))
|
||||
}
|
||||
|
||||
pub(crate) fn sign_in_message(&self) -> String {
|
||||
let name = &self.name;
|
||||
let hint = self.login_hint(Path::new(&self.command));
|
||||
format!("{name} needs you to sign in. {hint}")
|
||||
}
|
||||
|
||||
/// Resolve once before connecting, so authentication guidance names that same install.
|
||||
pub(crate) fn resolve_for_launch(mut self, cwd: &Path) -> Self {
|
||||
if self.driver != Driver::Acp {
|
||||
self.command = crate::native::newest_install(&self.command);
|
||||
}
|
||||
let program = ide_jsonrpc::resolve_spawn_program(&self.command, &self.env);
|
||||
self.command =
|
||||
if program.components().count() > 1 && !program.is_absolute() { cwd.join(program) } else { program }
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn render_login_hint(spec: &ProviderSpec, program: &Path, on_path: Option<&Path>, windows: bool) -> String {
|
||||
let Some(builtin) = builtin_spec(&spec.id) else { return spec.login_hint.clone() };
|
||||
let prefix = format!("Run `{}", builtin.command);
|
||||
let Some(rest) = spec.login_hint.strip_prefix(&prefix).filter(|rest| rest.starts_with([' ', '`'])) else {
|
||||
return spec.login_hint.clone();
|
||||
};
|
||||
let same_path = |a: &Path, b: &Path| {
|
||||
a == b || dunce::canonicalize(a).ok().zip(dunce::canonicalize(b).ok()).is_some_and(|(a, b)| a == b)
|
||||
};
|
||||
if on_path.is_some_and(|path| same_path(path, program)) {
|
||||
return spec.login_hint.clone();
|
||||
}
|
||||
let path = program.to_string_lossy();
|
||||
let quoted = if windows {
|
||||
format!("& '{}'", path.replace('\'', "''"))
|
||||
} else {
|
||||
format!("'{}'", path.replace('\'', "'\\''"))
|
||||
};
|
||||
let (args, suffix) = rest.split_once('`').expect("built-in login hints delimit their command");
|
||||
let command = format!("{quoted}{args}");
|
||||
match suffix {
|
||||
" in a terminal." => format!("Run `{command}` in a terminal."),
|
||||
" in a terminal and sign in." => format!("Run `{command}` in a terminal and sign in."),
|
||||
_ => spec.login_hint.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `id` names a built-in agent that needs no registry install (native drivers
|
||||
/// and `npx -y` adapters). CLI built-ins (`copilot`, `gemini`, …) can be installed and
|
||||
/// updated from the ACP Registry into `<config>/agents/<id>.toml`.
|
||||
@@ -119,7 +177,7 @@ pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider {
|
||||
args: spec.args.clone(),
|
||||
default_program: builtin.as_ref().map(|b| b.command.clone()),
|
||||
default_args: builtin.as_ref().map(|b| b.args.clone()).unwrap_or_default(),
|
||||
login_hint: Some(spec.login_hint.clone()),
|
||||
login_hint: Some(spec.login_hint(&ide_jsonrpc::resolve_spawn_program(&spec.command, &spec.env))),
|
||||
command_overridden: builtin.as_ref().is_some_and(|b| b.command != spec.command),
|
||||
command_editable: reason.is_none(),
|
||||
command_read_only_reason: reason,
|
||||
@@ -312,6 +370,81 @@ fn is_executable(p: &Path) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn login_hints_cover_all_builtin_commands_and_preserve_separate_login_tools() {
|
||||
for spec in builtins() {
|
||||
let separate = matches!(spec.id.as_str(), "claude-acp" | "codex-acp" | "vibe");
|
||||
for windows in [false, true] {
|
||||
let program =
|
||||
if windows { Path::new(r"C:\Agent Tools\renamed.cmd") } else { Path::new("/agent tools/renamed") };
|
||||
let hint = render_login_hint(&spec, program, None, windows);
|
||||
if separate {
|
||||
assert_eq!(hint, spec.login_hint, "{}", spec.id);
|
||||
} else {
|
||||
let prefix =
|
||||
if windows { "Run `& 'C:\\Agent Tools\\renamed.cmd'" } else { "Run `'/agent tools/renamed'" };
|
||||
assert!(hint.starts_with(prefix), "{}: {hint}", spec.id);
|
||||
assert_eq!(
|
||||
render_login_hint(&spec, program, Some(program), windows),
|
||||
spec.login_hint,
|
||||
"a PATH-resolved command stays bare: {}",
|
||||
spec.id
|
||||
);
|
||||
assert_eq!(render_login_hint(&spec, program, Some(Path::new("/other/install")), windows), hint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_commands_quote_shell_metacharacters_and_keep_arguments() {
|
||||
let cursor = builtin_spec("cursor").unwrap();
|
||||
assert_eq!(
|
||||
render_login_hint(&cursor, Path::new("/agent's tools/$cli"), None, false),
|
||||
"Run `'/agent'\\''s tools/$cli' login` in a terminal."
|
||||
);
|
||||
assert_eq!(
|
||||
render_login_hint(&cursor, Path::new(r"C:\agent's tools\$cli.cmd"), None, true),
|
||||
r"Run `& 'C:\agent''s tools\$cli.cmd' login` in a terminal."
|
||||
);
|
||||
let opencode = builtin_spec("opencode").unwrap();
|
||||
assert_eq!(
|
||||
render_login_hint(&opencode, Path::new("/registry/opencode"), None, false),
|
||||
"Run `'/registry/opencode' auth login` in a terminal."
|
||||
);
|
||||
let mut custom = cursor;
|
||||
custom.id = "custom".into();
|
||||
custom.login_hint = "Sign in with the agent's own command line first.".into();
|
||||
assert_eq!(render_login_hint(&custom, Path::new("/custom"), None, false), custom.login_hint);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_resolution_uses_the_child_path_and_absolutizes_relative_overrides() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cursor = builtin_spec("cursor").unwrap();
|
||||
let command = if cfg!(windows) { "cursor-agent.cmd" } else { "cursor-agent" };
|
||||
let program = dir.path().join(command);
|
||||
std::fs::write(&program, "").unwrap();
|
||||
cursor.env.push(("PATH".into(), dir.path().to_string_lossy().into_owned()));
|
||||
let resolved = cursor.clone().resolve_for_launch(dir.path());
|
||||
assert_eq!(Path::new(&resolved.command), program);
|
||||
assert!(resolved.sign_in_message().contains(&*program.to_string_lossy()));
|
||||
cursor.command = format!("tools/{command}");
|
||||
let resolved = cursor.resolve_for_launch(dir.path());
|
||||
assert_eq!(Path::new(&resolved.command), dir.path().join("tools").join(command));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn registry_login_hint_names_the_installed_executable() {
|
||||
let mut spec = builtin_spec("cursor").unwrap();
|
||||
spec.command = "/registry path/dist-package/cursor-agent".into();
|
||||
assert_eq!(
|
||||
describe(&spec).login_hint.as_deref(),
|
||||
Some("Run `'/registry path/dist-package/cursor-agent' login` in a terminal.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_metadata_distinguishes_defaults_overrides_and_read_only_agents() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -11,6 +11,7 @@ struct FakeAgent {
|
||||
calls: Mutex<Vec<String>>,
|
||||
/// Without `loadSession` (a session lives only as long as its process).
|
||||
no_load: bool,
|
||||
auth_on_new: bool,
|
||||
/// Config options `session/new` reports after the model one (per-model efforts, say).
|
||||
extra_options: Mutex<Vec<Value>>,
|
||||
}
|
||||
@@ -87,6 +88,11 @@ impl Handler for FakeAgent {
|
||||
|
||||
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
|
||||
self.calls.lock().push(method.to_owned());
|
||||
if (method == "session/new" && self.auth_on_new)
|
||||
|| (method == "session/prompt" && params.pointer("/prompt/0/text") == Some(&json!("auth")))
|
||||
{
|
||||
return Err((codes::INTERNAL_ERROR, "Authentication required".into()));
|
||||
}
|
||||
let session = params.get("sessionId").and_then(Value::as_str).unwrap_or("s1").to_owned();
|
||||
match method {
|
||||
"initialize" => {
|
||||
@@ -250,6 +256,49 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentMan
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn authentication_failures_name_the_session_executable_at_start_and_during_prompts() {
|
||||
for auth_on_new in [true, false] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent { auth_on_new, ..Default::default() });
|
||||
let mgr = manager(tmp.path(), agent);
|
||||
let program = tmp.path().join("Agent Tools").join("renamed-cursor.cmd");
|
||||
mgr.shared.providers.write().iter_mut().find(|s| s.id == "cursor").unwrap().command =
|
||||
program.to_string_lossy().into_owned();
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("again"),
|
||||
provider: "cursor".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
if !auth_on_new {
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
// A settings change must not redirect guidance for the already running process.
|
||||
mgr.shared.providers.write().iter_mut().find(|s| s.id == "cursor").unwrap().command =
|
||||
"different-cursor".into();
|
||||
mgr.prompt(&task.id, text("auth"), false).await.unwrap();
|
||||
}
|
||||
wait_for(&mgr, &task.id, AgentStatus::Failed).await;
|
||||
let entries = mgr.entries(&task.id, None).unwrap();
|
||||
let error = entries
|
||||
.iter()
|
||||
.find_map(|e| match e {
|
||||
AgentEntry::Error { message, .. } => Some(message),
|
||||
_ => None,
|
||||
})
|
||||
.expect("authentication error");
|
||||
assert!(error.starts_with("Cursor needs you to sign in. Run `"), "{error}");
|
||||
assert!(error.contains(&*program.to_string_lossy()), "{error}");
|
||||
assert!(error.ends_with(" login` in a terminal."), "{error}");
|
||||
mgr.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "ide-cli"
|
||||
description = "The `openidea` command: arguments, the request it sends a running app over a local socket, and installing it on PATH"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
dirs.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
tokio = { workspace = true, features = ["net"] }
|
||||
winreg = "0.56"
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_UI_WindowsAndMessaging"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Command-line arguments of `openidea`.
|
||||
|
||||
use crate::Target;
|
||||
use std::path::Path;
|
||||
|
||||
pub const USAGE: &str = "\
|
||||
Usage: openidea [options] [[--line <n>] [--column <n>] path[:line[:column]] ...]
|
||||
openidea diff <left> <right>
|
||||
|
||||
Opens folders as projects and files in the editor of the running OpenIDEA,
|
||||
starting it if needed. Without paths, brings OpenIDEA to the front.
|
||||
Lines and columns start at 1; --line and --column apply to the next path.
|
||||
|
||||
Options:
|
||||
--line <n> Put the caret on line <n> of the next file
|
||||
--column <n> Put the caret in column <n> of that line
|
||||
-h, --help Show this help
|
||||
-v, --version Show the version
|
||||
";
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Command {
|
||||
Help,
|
||||
Version,
|
||||
Open(Vec<Target>),
|
||||
Diff(Vec<Target>),
|
||||
}
|
||||
|
||||
/// Parses `args` (without the program name); relative paths resolve against `cwd`.
|
||||
pub fn parse(args: &[String], cwd: &Path) -> Result<Command, String> {
|
||||
parse_with(args, cwd, &|p| p.exists())
|
||||
}
|
||||
|
||||
fn parse_with(args: &[String], cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Command, String> {
|
||||
if args.first().is_some_and(|arg| arg == "diff") {
|
||||
return match parse_paths(&args[1..], cwd, exists)? {
|
||||
Command::Open(targets) if targets.len() == 2 => Ok(Command::Diff(targets)),
|
||||
Command::Help => Ok(Command::Help),
|
||||
_ => Err("diff needs two files".into()),
|
||||
};
|
||||
}
|
||||
parse_paths(args, cwd, exists)
|
||||
}
|
||||
|
||||
fn parse_paths(args: &[String], cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Command, String> {
|
||||
let mut targets = Vec::new();
|
||||
let mut options = true;
|
||||
let (mut line, mut column) = (None, None);
|
||||
let mut args = args.iter();
|
||||
while let Some(arg) = args.next() {
|
||||
let (flag, inline) = match arg.split_once('=') {
|
||||
Some((flag, value)) if options && flag.starts_with("--") => (flag, Some(value.to_owned())),
|
||||
_ => (arg.as_str(), None),
|
||||
};
|
||||
match flag {
|
||||
"--" if options => options = false,
|
||||
"-h" | "--help" if options => return Ok(Command::Help),
|
||||
"-v" | "--version" if options => return Ok(Command::Version),
|
||||
"--line" | "--column" if options => {
|
||||
let value = inline.or_else(|| args.next().cloned()).ok_or(format!("{flag} needs a number"))?;
|
||||
let n = position(&value).ok_or(format!("{flag}: not a number from 1: {value}"))?;
|
||||
*if flag == "--line" { &mut line } else { &mut column } = Some(n);
|
||||
}
|
||||
a if options && a.starts_with('-') && a != "-" => return Err(format!("unknown option: {a}")),
|
||||
_ => {
|
||||
let mut target = target(arg, cwd, exists)?;
|
||||
if line.is_some() || column.is_some() {
|
||||
target.line = line.take().or(target.line).or(Some(1));
|
||||
target.column = column.take();
|
||||
}
|
||||
targets.push(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
if line.is_some() || column.is_some() {
|
||||
return Err("--line and --column need a file after them".into());
|
||||
}
|
||||
Ok(Command::Open(targets))
|
||||
}
|
||||
|
||||
/// `arg` as a path, else as `path:line[:column]` when `path` exists.
|
||||
fn target(arg: &str, cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Target, String> {
|
||||
let path = cwd.join(arg);
|
||||
if exists(&path) {
|
||||
return Ok(Target { path, line: None, column: None });
|
||||
}
|
||||
for (base, line, column) in split_position(arg) {
|
||||
let path = cwd.join(base);
|
||||
if exists(&path) {
|
||||
return Ok(Target { path, line: Some(line), column });
|
||||
}
|
||||
}
|
||||
Err(format!("{arg}: no such file or directory"))
|
||||
}
|
||||
|
||||
/// The readings of `arg` as `path:line:column`, then as `path:line`.
|
||||
fn split_position(arg: &str) -> Vec<(&str, u32, Option<u32>)> {
|
||||
let Some((rest, last)) = arg.rsplit_once(':') else { return Vec::new() };
|
||||
let Some(last) = position(last) else { return Vec::new() };
|
||||
let mut readings = Vec::new();
|
||||
if let Some((base, line)) = rest.rsplit_once(':')
|
||||
&& let Some(line) = position(line)
|
||||
&& !base.is_empty()
|
||||
{
|
||||
readings.push((base, line, Some(last)));
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
readings.push((rest, last, None));
|
||||
}
|
||||
readings
|
||||
}
|
||||
|
||||
fn position(s: &str) -> Option<u32> {
|
||||
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
s.parse().ok().filter(|n| *n > 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn args(list: &[&str]) -> Vec<String> {
|
||||
list.iter().map(|s| (*s).to_owned()).collect()
|
||||
}
|
||||
|
||||
fn parse_in(list: &[&str], existing: &[&str]) -> Result<Command, String> {
|
||||
let existing: Vec<PathBuf> = existing.iter().map(PathBuf::from).collect();
|
||||
parse_with(&args(list), Path::new("/work"), &|p| existing.iter().any(|e| e == p))
|
||||
}
|
||||
|
||||
fn at(path: &str, line: Option<u32>, column: Option<u32>) -> Target {
|
||||
Target { path: path.into(), line, column }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_resolve_against_cwd() {
|
||||
assert_eq!(
|
||||
parse_in(&["src", "/abs/a.rs"], &["/work/src", "/abs/a.rs"]),
|
||||
Ok(Command::Open(vec![at("/work/src", None, None), at("/abs/a.rs", None, None)]))
|
||||
);
|
||||
assert_eq!(parse_in(&[], &[]), Ok(Command::Open(vec![])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positions_split_only_when_the_whole_arg_is_missing() {
|
||||
let files = ["/work/a.rs", "/work/b:2"];
|
||||
assert_eq!(parse_in(&["a.rs:12"], &files), Ok(Command::Open(vec![at("/work/a.rs", Some(12), None)])));
|
||||
assert_eq!(parse_in(&["a.rs:12:5"], &files), Ok(Command::Open(vec![at("/work/a.rs", Some(12), Some(5))])));
|
||||
assert_eq!(parse_in(&["b:2"], &files), Ok(Command::Open(vec![at("/work/b:2", None, None)])));
|
||||
assert_eq!(parse_in(&["b:2:7"], &files), Ok(Command::Open(vec![at("/work/b:2", Some(7), None)])));
|
||||
assert!(parse_in(&["a.rs:0"], &files).is_err());
|
||||
assert!(parse_in(&["a.rs:x"], &files).is_err());
|
||||
assert!(parse_in(&["missing.rs:3"], &files).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_drive_colon_is_not_a_position() {
|
||||
assert_eq!(split_position(r"C:\a.rs:10"), vec![(r"C:\a.rs", 10, None)]);
|
||||
assert_eq!(split_position(r"C:\a.rs:10:2"), vec![(r"C:\a.rs", 10, Some(2)), (r"C:\a.rs:10", 2, None)]);
|
||||
assert!(split_position(r"C:\a.rs").is_empty());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_absolute_and_unc_targets_keep_spaces_and_unicode() {
|
||||
let local = PathBuf::from(r"C:\项目\my file.rs");
|
||||
let network = PathBuf::from(r"\\server\share\folder name");
|
||||
let command = parse_with(
|
||||
&args(&[r"C:\项目\my file.rs:12:5", r"\\server\share\folder name"]),
|
||||
Path::new(r"D:\work"),
|
||||
&|path| path == local || path == network,
|
||||
);
|
||||
assert_eq!(
|
||||
command,
|
||||
Ok(Command::Open(vec![
|
||||
Target { path: local, line: Some(12), column: Some(5) },
|
||||
Target { path: network, line: None, column: None },
|
||||
]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_and_column_flags_apply_to_the_next_path() {
|
||||
let files = ["/work/a.rs", "/work/b.rs"];
|
||||
assert_eq!(
|
||||
parse_in(&["--line", "12", "--column=5", "a.rs", "b.rs"], &files),
|
||||
Ok(Command::Open(vec![at("/work/a.rs", Some(12), Some(5)), at("/work/b.rs", None, None)]))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_in(&["--line=3", "a.rs:9:9"], &files),
|
||||
Ok(Command::Open(vec![at("/work/a.rs", Some(3), None)]))
|
||||
);
|
||||
assert!(parse_in(&["--line", "0", "a.rs"], &files).is_err());
|
||||
assert!(parse_in(&["--line"], &files).is_err());
|
||||
assert!(parse_in(&["a.rs", "--line", "2"], &files).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options() {
|
||||
assert_eq!(parse_in(&["-h"], &[]), Ok(Command::Help));
|
||||
assert_eq!(parse_in(&["--version"], &[]), Ok(Command::Version));
|
||||
assert!(parse_in(&["--nope"], &[]).is_err());
|
||||
assert_eq!(parse_in(&["--", "-h"], &["/work/-h"]), Ok(Command::Open(vec![at("/work/-h", None, None)])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_diff_command_is_supported() {
|
||||
let files = ["/work/a.rs", "/work/b.rs"];
|
||||
assert_eq!(
|
||||
parse_in(&["diff", "a.rs", "b.rs"], &files),
|
||||
Ok(Command::Diff(vec![at("/work/a.rs", None, None), at("/work/b.rs", None, None),]))
|
||||
);
|
||||
assert!(parse_in(&["diff", "a.rs"], &files).is_err());
|
||||
assert_eq!(
|
||||
parse_in(&["diff", "diff", "b.rs"], &["/work/diff", "/work/b.rs"]),
|
||||
Ok(Command::Diff(vec![at("/work/diff", None, None), at("/work/b.rs", None, None),]))
|
||||
);
|
||||
assert_eq!(parse_in(&["--", "diff"], &["/work/diff"]), Ok(Command::Open(vec![at("/work/diff", None, None)])));
|
||||
assert_eq!(
|
||||
parse_in(&["--column=4", "a.rs", "b.rs"], &files),
|
||||
Ok(Command::Open(vec![at("/work/a.rs", Some(1), Some(4)), at("/work/b.rs", None, None),]))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
//! Installing the `openidea` command: a symlink on `PATH` to the launcher
|
||||
//! script in a macOS bundle, or a wrapper in a user's bin directory.
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const COMMAND: &str = "openidea";
|
||||
const LAUNCHER: &str = "Contents/Resources/bin/openidea";
|
||||
const SYSTEM_DIR: &str = "/usr/local/bin";
|
||||
|
||||
/// The launcher script of the bundle whose executable is `exe`; `None` outside a bundle.
|
||||
pub fn bundled_launcher(exe: &Path) -> Option<PathBuf> {
|
||||
let launcher = exe.parent()?.parent()?.parent()?.join(LAUNCHER);
|
||||
launcher.is_file().then_some(launcher)
|
||||
}
|
||||
|
||||
/// macOS runs a quarantined app from a random read-only copy until it is moved:
|
||||
/// a link there would break on the next launch.
|
||||
pub fn translocated(path: &Path) -> bool {
|
||||
path.to_string_lossy().contains("/AppTranslocation/")
|
||||
}
|
||||
|
||||
/// Where the link goes: `~/.local/bin` when it is on `path_var` (no administrator
|
||||
/// rights needed), else `/usr/local/bin`.
|
||||
pub fn link_dir(path_var: Option<&OsStr>, home: Option<&Path>) -> PathBuf {
|
||||
let local = home.map(|h| h.join(".local/bin"));
|
||||
match (path_var, local) {
|
||||
(Some(var), Some(local)) if std::env::split_paths(var).any(|p| p == local) => local,
|
||||
_ => PathBuf::from(SYSTEM_DIR),
|
||||
}
|
||||
}
|
||||
|
||||
/// Directories an earlier install may have used.
|
||||
pub fn link_dirs(home: Option<&Path>) -> Vec<PathBuf> {
|
||||
home.map(|h| h.join(".local/bin")).into_iter().chain([PathBuf::from(SYSTEM_DIR)]).collect()
|
||||
}
|
||||
|
||||
/// The installed links among `dirs` (any OpenIDEA bundle's launcher).
|
||||
pub fn installed(dirs: &[PathBuf]) -> Vec<PathBuf> {
|
||||
dirs.iter().map(|d| d.join(COMMAND)).filter(|l| is_launcher_link(l)).collect()
|
||||
}
|
||||
|
||||
fn is_launcher_link(link: &Path) -> bool {
|
||||
std::fs::read_link(link).is_ok_and(|target| target.ends_with(LAUNCHER))
|
||||
}
|
||||
|
||||
/// Links `dir/openidea` to `launcher`, asking for administrator rights when `dir`
|
||||
/// is not writable. Refuses to replace an `openidea` that is not ours.
|
||||
pub fn install(launcher: &Path, dir: &Path) -> io::Result<PathBuf> {
|
||||
let link = dir.join(COMMAND);
|
||||
if link.symlink_metadata().is_ok() && !is_launcher_link(&link) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
format!("{} already exists and is not an OpenIDEA command", link.display()),
|
||||
));
|
||||
}
|
||||
let direct = std::fs::create_dir_all(dir).and_then(|()| {
|
||||
if link.symlink_metadata().is_ok() {
|
||||
std::fs::remove_file(&link)?;
|
||||
}
|
||||
symlink(launcher, &link)
|
||||
});
|
||||
match direct {
|
||||
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => {
|
||||
privileged(&format!("mkdir -p {} && ln -sfn {} {}", quote(dir), quote(launcher), quote(&link)))?
|
||||
}
|
||||
result => result?,
|
||||
}
|
||||
Ok(link)
|
||||
}
|
||||
|
||||
/// Removes `links`, asking for administrator rights where needed.
|
||||
pub fn uninstall(links: &[PathBuf]) -> io::Result<()> {
|
||||
for link in links {
|
||||
match std::fs::remove_file(link) {
|
||||
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => privileged(&format!("rm -f {}", quote(link)))?,
|
||||
result => result?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn symlink(target: &Path, link: &Path) -> io::Result<()> {
|
||||
std::os::unix::fs::symlink(target, link)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn symlink(_target: &Path, _link: &Path) -> io::Result<()> {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported, "not supported on this platform"))
|
||||
}
|
||||
|
||||
/// Runs `command` as root after the system's password prompt. Cancelling it is
|
||||
/// [`io::ErrorKind::Interrupted`].
|
||||
fn privileged(command: &str) -> io::Result<()> {
|
||||
if !cfg!(target_os = "macos") {
|
||||
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "administrator rights required"));
|
||||
}
|
||||
let script = format!("do shell script \"{}\" with administrator privileges", applescript_escape(command));
|
||||
let output = std::process::Command::new("/usr/bin/osascript").arg("-e").arg(script).output()?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let message = String::from_utf8_lossy(&output.stderr).trim().to_owned();
|
||||
// "User canceled. (-128)"
|
||||
let kind = if message.contains("(-128)") { io::ErrorKind::Interrupted } else { io::ErrorKind::PermissionDenied };
|
||||
Err(io::Error::new(kind, message))
|
||||
}
|
||||
|
||||
fn quote(path: &Path) -> String {
|
||||
format!("'{}'", path.to_string_lossy().replace('\'', r"'\''"))
|
||||
}
|
||||
|
||||
fn applescript_escape(s: &str) -> String {
|
||||
s.replace('\\', r"\\").replace('"', "\\\"")
|
||||
}
|
||||
|
||||
const WRAPPER_MARKER: &str = "OpenIDEA command launcher v1";
|
||||
|
||||
/// AppImages remount their executable on every launch. Keep the original image path.
|
||||
pub fn stable_executable(exe: &Path, appimage: Option<&Path>) -> io::Result<PathBuf> {
|
||||
let path = appimage.unwrap_or(exe);
|
||||
if !path.is_absolute() || !path.is_file() {
|
||||
return Err(io::Error::new(io::ErrorKind::NotFound, "the OpenIDEA executable must be in a permanent location"));
|
||||
}
|
||||
Ok(path.to_path_buf())
|
||||
}
|
||||
|
||||
pub fn wrapper_path(dir: &Path, windows: bool) -> PathBuf {
|
||||
dir.join(if windows { "openidea.cmd" } else { COMMAND })
|
||||
}
|
||||
|
||||
fn wrapper(exe: &Path, windows: bool) -> io::Result<String> {
|
||||
let exe =
|
||||
exe.to_str().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "the executable path is not UTF-8"))?;
|
||||
if exe.contains(['\r', '\n']) || (windows && exe.contains('"')) {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "the executable path contains unsupported characters"));
|
||||
}
|
||||
Ok(if windows {
|
||||
format!(
|
||||
"@echo off\r\nrem {WRAPPER_MARKER}\r\nsetlocal DisableDelayedExpansion\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"_openidea_codepage=%%c\"\r\n\"%SystemRoot%\\System32\\chcp.com\" 65001 >nul\r\n\"{}\" --cli %*\r\nset \"_openidea_exit=%errorlevel%\"\r\n\"%SystemRoot%\\System32\\chcp.com\" %_openidea_codepage% >nul\r\nexit /b %_openidea_exit%\r\n",
|
||||
exe.replace('%', "%%")
|
||||
)
|
||||
} else {
|
||||
format!("#!/bin/sh\n# {WRAPPER_MARKER}\nexec {} --cli \"$@\"\n", quote(Path::new(exe)))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_wrapper(path: &Path, windows: bool) -> bool {
|
||||
path.symlink_metadata().is_ok_and(|meta| meta.is_file() && !meta.file_type().is_symlink())
|
||||
&& std::fs::read_to_string(path).is_ok_and(|text| {
|
||||
text.lines().nth(1) == Some(&format!("{} {WRAPPER_MARKER}", if windows { "rem" } else { "#" }))
|
||||
})
|
||||
}
|
||||
|
||||
/// Never follows or overwrites a foreign file, even if it has the same command name.
|
||||
pub fn install_wrapper(exe: &Path, dir: &Path, windows: bool) -> io::Result<PathBuf> {
|
||||
use std::io::Write;
|
||||
let text = wrapper(exe, windows)?;
|
||||
let path = wrapper_path(dir, windows);
|
||||
if path.symlink_metadata().is_ok() {
|
||||
if !is_wrapper(&path, windows) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
format!("{} already exists and is not an OpenIDEA command", path.display()),
|
||||
));
|
||||
}
|
||||
std::fs::remove_file(&path)?;
|
||||
}
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o755))?;
|
||||
}
|
||||
file.write_all(text.as_bytes())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn uninstall_wrapper(dir: &Path, windows: bool) -> io::Result<Vec<PathBuf>> {
|
||||
let path = wrapper_path(dir, windows);
|
||||
if !is_wrapper(&path, windows) {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
std::fs::remove_file(&path)?;
|
||||
Ok(vec![path])
|
||||
}
|
||||
|
||||
/// Preserve every unrelated PATH entry, including expandable environment variables.
|
||||
pub fn windows_path(current: &str, dir: &str, add: bool) -> Option<String> {
|
||||
fn equal(a: &str, b: &str) -> bool {
|
||||
a.trim().trim_matches('"').trim_end_matches(['\\', '/']).eq_ignore_ascii_case(b.trim_end_matches(['\\', '/']))
|
||||
}
|
||||
let found = current.split(';').any(|entry| equal(entry, dir));
|
||||
if add {
|
||||
if found {
|
||||
return None;
|
||||
}
|
||||
Some(format!("{dir}{}{current}", if current.is_empty() { "" } else { ";" }))
|
||||
} else if found {
|
||||
Some(current.split(';').filter(|entry| !equal(entry, dir)).collect::<Vec<_>>().join(";"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn update_user_path(dir: &Path, add: bool) -> io::Result<()> {
|
||||
use winreg::{
|
||||
RegKey,
|
||||
enums::{HKEY_CURRENT_USER, REG_EXPAND_SZ},
|
||||
types::{FromRegValue, ToRegValue},
|
||||
};
|
||||
let (environment, _) = RegKey::predef(HKEY_CURRENT_USER).create_subkey("Environment")?;
|
||||
let raw = match environment.get_raw_value("Path") {
|
||||
Ok(raw) => Some(raw),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let current = raw.as_ref().map(String::from_reg_value).transpose()?.unwrap_or_default();
|
||||
let Some(updated) = windows_path(¤t, &dir.to_string_lossy(), add) else { return Ok(()) };
|
||||
let mut value = updated.to_reg_value();
|
||||
value.vtype = raw.as_ref().map_or(REG_EXPAND_SZ, |raw| raw.vtype.clone());
|
||||
environment.set_raw_value("Path", &value)?;
|
||||
let message: Vec<u16> = "Environment\0".encode_utf16().collect();
|
||||
// SAFETY: the terminated string stays alive for this bounded synchronous broadcast.
|
||||
unsafe {
|
||||
windows_sys::Win32::UI::WindowsAndMessaging::SendMessageTimeoutW(
|
||||
windows_sys::Win32::UI::WindowsAndMessaging::HWND_BROADCAST,
|
||||
windows_sys::Win32::UI::WindowsAndMessaging::WM_SETTINGCHANGE,
|
||||
0,
|
||||
message.as_ptr() as isize,
|
||||
windows_sys::Win32::UI::WindowsAndMessaging::SMTO_ABORTIFHUNG,
|
||||
1000,
|
||||
std::ptr::null_mut(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct Installation {
|
||||
pub path: PathBuf,
|
||||
/// Some when the user needs to add this directory to their shell's PATH.
|
||||
pub path_directory: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn resolves_to(path_var: Option<&OsStr>, command: &Path) -> bool {
|
||||
path_var
|
||||
.and_then(|path| std::env::split_paths(path).map(|dir| dir.join(COMMAND)).find(|candidate| candidate.is_file()))
|
||||
.is_some_and(|candidate| candidate == command)
|
||||
}
|
||||
|
||||
fn user_bin() -> io::Result<PathBuf> {
|
||||
if cfg!(windows) {
|
||||
dirs::data_local_dir().map(|dir| dir.join("OpenIDEA/bin"))
|
||||
} else {
|
||||
dirs::home_dir().map(|dir| dir.join(".local/bin"))
|
||||
}
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no user directory for the openidea command"))
|
||||
}
|
||||
|
||||
pub fn install_current() -> io::Result<Installation> {
|
||||
let exe = std::env::current_exe()?;
|
||||
if cfg!(target_os = "macos") {
|
||||
let launcher = bundled_launcher(&exe).ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::NotFound, "install the command from the packaged OpenIDEA app")
|
||||
})?;
|
||||
if translocated(&launcher) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"move OpenIDEA to the Applications folder first",
|
||||
));
|
||||
}
|
||||
let dir = link_dir(std::env::var_os("PATH").as_deref(), dirs::home_dir().as_deref());
|
||||
return Ok(Installation { path: install(&launcher, &dir)?, path_directory: None });
|
||||
}
|
||||
let image = if cfg!(target_os = "linux") { std::env::var_os("APPIMAGE").map(PathBuf::from) } else { None };
|
||||
let exe = stable_executable(&exe, image.as_deref())?;
|
||||
let dir = user_bin()?;
|
||||
let path = install_wrapper(&exe, &dir, cfg!(windows))?;
|
||||
#[cfg(windows)]
|
||||
update_user_path(&dir, true)?;
|
||||
let on_path = cfg!(windows) || resolves_to(std::env::var_os("PATH").as_deref(), &path);
|
||||
Ok(Installation { path, path_directory: (!on_path).then_some(dir) })
|
||||
}
|
||||
|
||||
pub fn uninstall_current() -> io::Result<Vec<PathBuf>> {
|
||||
if cfg!(target_os = "macos") {
|
||||
let links = installed(&link_dirs(dirs::home_dir().as_deref()));
|
||||
uninstall(&links)?;
|
||||
return Ok(links);
|
||||
}
|
||||
let dir = user_bin()?;
|
||||
let path = wrapper_path(&dir, cfg!(windows));
|
||||
if is_wrapper(&path, cfg!(windows)) {
|
||||
#[cfg(windows)]
|
||||
update_user_path(&dir, false)?;
|
||||
return uninstall_wrapper(&dir, cfg!(windows));
|
||||
}
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod wrapper_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wrappers_install_update_and_remove_only_their_own_files() {
|
||||
for windows in [false, true] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let exe = dir.path().join("App folder/程序 & it's 100%.exe");
|
||||
let path = install_wrapper(&exe, dir.path(), windows).unwrap();
|
||||
assert!(is_wrapper(&path, windows));
|
||||
assert_eq!(install_wrapper(&exe, dir.path(), windows).unwrap(), path);
|
||||
assert_eq!(uninstall_wrapper(dir.path(), windows).unwrap(), vec![path.clone()]);
|
||||
assert!(uninstall_wrapper(dir.path(), windows).unwrap().is_empty());
|
||||
std::fs::write(&path, "foreign command").unwrap();
|
||||
assert_eq!(install_wrapper(&exe, dir.path(), windows).unwrap_err().kind(), io::ErrorKind::AlreadyExists);
|
||||
assert!(uninstall_wrapper(dir.path(), windows).unwrap().is_empty());
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "foreign command");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_changes_keep_other_entries_and_do_not_duplicate_the_command_dir() {
|
||||
let dir = r"C:\Users\用户\OpenIDEA\bin";
|
||||
let initial = r"%USERPROFILE%\tools;C:\other";
|
||||
let updated = windows_path(initial, dir, true).unwrap();
|
||||
assert_eq!(updated, format!("{dir};{initial}"));
|
||||
assert_eq!(windows_path(&updated, dir, true), None);
|
||||
assert_eq!(windows_path(&updated, dir, false).as_deref(), Some(initial));
|
||||
assert_eq!(
|
||||
windows_path(&format!("\"{dir}\\\";C:\\other"), &dir.to_uppercase(), false).as_deref(),
|
||||
Some(r"C:\other")
|
||||
);
|
||||
assert_eq!(windows_path(initial, dir, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appimage_uses_the_persistent_image_not_the_mounted_binary() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let image = dir.path().join("OpenIDEA.AppImage");
|
||||
std::fs::write(&image, "image").unwrap();
|
||||
assert_eq!(stable_executable(Path::new("/tmp/.mount_App/usr/bin/openidea"), Some(&image)).unwrap(), image);
|
||||
assert_eq!(stable_executable(&image, None).unwrap(), image);
|
||||
assert!(stable_executable(Path::new("missing"), None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_a_system_binary_shadowing_the_linux_wrapper() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let system = dir.path().join("system");
|
||||
let user = dir.path().join("user");
|
||||
for bin in [&system, &user] {
|
||||
std::fs::create_dir_all(bin).unwrap();
|
||||
std::fs::write(bin.join(COMMAND), "command").unwrap();
|
||||
}
|
||||
let path = std::env::join_paths([&system, &user]).unwrap();
|
||||
assert!(!resolves_to(Some(&path), &user.join(COMMAND)));
|
||||
let path = std::env::join_paths([&user, &system]).unwrap();
|
||||
assert!(resolves_to(Some(&path), &user.join(COMMAND)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn posix_wrapper_preserves_arguments_and_exit_status() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let exe = dir.path().join("app & it's $dollar `quoted` \"double\"");
|
||||
std::fs::write(&exe, "#!/bin/sh\nprintf '%s\\n' \"$@\"\nexit 7\n").unwrap();
|
||||
std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let path = install_wrapper(&exe, &dir.path().join("bin"), false).unwrap();
|
||||
let output = std::process::Command::new(path).args(["a b", "中文", "x'$`&"]).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(7));
|
||||
assert_eq!(String::from_utf8(output.stdout).unwrap(), "--cli\na b\n中文\nx'$`&\n");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn cmd_wrapper_preserves_arguments_and_exit_status() {
|
||||
use std::os::windows::process::CommandExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let exe = dir.path().join("程序 & 100% !special!.exe");
|
||||
let source = dir.path().join("helper.rs");
|
||||
std::fs::write(
|
||||
&source,
|
||||
r#"fn main() { for arg in std::env::args().skip(1) { println!("{arg}"); } std::process::exit(7); }"#,
|
||||
)
|
||||
.unwrap();
|
||||
let compiled = std::process::Command::new("rustc")
|
||||
.args(["--crate-name", "launcher_test"])
|
||||
.arg(&source)
|
||||
.arg("-o")
|
||||
.arg(&exe)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(compiled.status.success(), "{}", String::from_utf8_lossy(&compiled.stderr));
|
||||
let path = install_wrapper(&exe, &dir.path().join("bin"), true).unwrap();
|
||||
std::fs::write(dir.path().join("chcp.cmd"), "@echo malicious-chcp\r\nexit /b 99\r\n").unwrap();
|
||||
let output = std::process::Command::new("cmd.exe")
|
||||
.current_dir(dir.path())
|
||||
.args(["/d", "/s", "/c"])
|
||||
.raw_arg(format!("\"\"{}\" \"a b\" \"中文\" \"x&y\"\"", path.display()))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(7));
|
||||
assert_eq!(String::from_utf8(output.stdout).unwrap().replace("\r\n", "\n"), "--cli\na b\n中文\nx&y\n");
|
||||
let check = dir.path().join("check.cmd");
|
||||
std::fs::write(&check, "@echo off\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"before=%%c\"\r\ncall \"%~dp0bin\\openidea.cmd\" example\r\nset \"code=%errorlevel%\"\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"after=%%c\"\r\nif not \"%before%\"==\"%after%\" exit /b 99\r\nexit /b %code%\r\n").unwrap();
|
||||
let output = std::process::Command::new("cmd.exe")
|
||||
.current_dir(dir.path())
|
||||
.args(["/d", "/s", "/c"])
|
||||
.raw_arg(format!("\"\"{}\"\"", check.display()))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(7));
|
||||
assert!(!String::from_utf8_lossy(&output.stdout).contains("malicious-chcp"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn bundle(root: &Path) -> (PathBuf, PathBuf) {
|
||||
let app = root.join("OpenIDEA.app");
|
||||
let exe = app.join("Contents/MacOS/openidea");
|
||||
let launcher = app.join(LAUNCHER);
|
||||
std::fs::create_dir_all(exe.parent().unwrap()).unwrap();
|
||||
std::fs::create_dir_all(launcher.parent().unwrap()).unwrap();
|
||||
std::fs::write(&exe, "").unwrap();
|
||||
std::fs::write(&launcher, "").unwrap();
|
||||
(exe, launcher)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_the_launcher_of_a_bundle_only() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (exe, launcher) = bundle(dir.path());
|
||||
assert_eq!(bundled_launcher(&exe), Some(launcher));
|
||||
assert_eq!(bundled_launcher(&dir.path().join("target/debug/openidea")), None);
|
||||
assert!(translocated(Path::new("/private/var/folders/x/AppTranslocation/ABC/d/OpenIDEA.app")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_local_bin_on_path() {
|
||||
let home = Path::new("/Users/u");
|
||||
let path = std::env::join_paths(["/usr/bin", "/Users/u/.local/bin"]).unwrap();
|
||||
assert_eq!(link_dir(Some(&path), Some(home)), home.join(".local/bin"));
|
||||
assert_eq!(link_dir(Some(OsStr::new("/usr/bin")), Some(home)), PathBuf::from(SYSTEM_DIR));
|
||||
assert_eq!(link_dir(None, Some(home)), PathBuf::from(SYSTEM_DIR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_replaces_its_own_link_but_not_other_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (_, launcher) = bundle(dir.path());
|
||||
let bin = dir.path().join("bin");
|
||||
let link = install(&launcher, &bin).unwrap();
|
||||
assert_eq!(std::fs::read_link(&link).unwrap(), launcher);
|
||||
assert_eq!(install(&launcher, &bin).unwrap(), link);
|
||||
assert_eq!(installed(std::slice::from_ref(&bin)), vec![link.clone()]);
|
||||
uninstall(&installed(std::slice::from_ref(&bin))).unwrap();
|
||||
assert!(installed(std::slice::from_ref(&bin)).is_empty());
|
||||
std::fs::write(&link, "someone else's").unwrap();
|
||||
assert_eq!(install(&launcher, &bin).unwrap_err().kind(), io::ErrorKind::AlreadyExists);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoting() {
|
||||
assert_eq!(quote(Path::new("/a b/it's")), r"'/a b/it'\''s'");
|
||||
assert_eq!(applescript_escape(r#"ln "a\b""#), r#"ln \"a\\b\""#);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! The `openidea` command.
|
||||
//!
|
||||
//! The command is the app's own executable run in CLI mode: it parses its
|
||||
//! arguments ([`args`]), sends one [`Request`] to the running app over a local
|
||||
//! socket ([`socket`]) and exits. [`install`] links the bundle's launcher
|
||||
//! script into a directory on `PATH`.
|
||||
|
||||
pub mod args;
|
||||
pub mod install;
|
||||
pub mod socket;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Bumped when [`Request`] changes incompatibly; an app still running an older
|
||||
/// version answers with an error instead of misreading it.
|
||||
pub const PROTOCOL_VERSION: u32 = 1;
|
||||
|
||||
/// A file or folder to open, with an optional 1-based position in a file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Target {
|
||||
/// Absolute.
|
||||
pub path: PathBuf,
|
||||
pub line: Option<u32>,
|
||||
pub column: Option<u32>,
|
||||
}
|
||||
|
||||
/// What the command asks the running app to do. No targets: bring the app to the front.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Request {
|
||||
pub version: u32,
|
||||
pub targets: Vec<Target>,
|
||||
#[serde(default)]
|
||||
pub diff: bool,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
pub fn open(targets: Vec<Target>) -> Self {
|
||||
Self { version: PROTOCOL_VERSION, targets, diff: false }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum Response {
|
||||
Ok,
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
/// A GUI subsystem executable inherits redirected handles, but needs its parent's
|
||||
/// console for interactive help and errors. Batch launchers wait for this process.
|
||||
#[cfg(windows)]
|
||||
pub fn attach_console() {
|
||||
use windows_sys::Win32::Storage::FileSystem::{FILE_TYPE_DISK, FILE_TYPE_PIPE, GetFileType};
|
||||
use windows_sys::Win32::System::Console::{
|
||||
ATTACH_PARENT_PROCESS, AttachConsole, GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE,
|
||||
SetStdHandle,
|
||||
};
|
||||
// SAFETY: attaches only this CLI process, without allocating a new console.
|
||||
unsafe {
|
||||
let redirected = [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE].map(|kind| {
|
||||
let handle = GetStdHandle(kind);
|
||||
(kind, handle, matches!(GetFileType(handle), FILE_TYPE_DISK | FILE_TYPE_PIPE))
|
||||
});
|
||||
AttachConsole(ATTACH_PARENT_PROCESS);
|
||||
for (kind, handle, keep) in redirected {
|
||||
if keep {
|
||||
SetStdHandle(kind, handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
//! The local socket the running app listens on: one JSON [`Request`] line in,
|
||||
//! one [`Response`] line out. Unix domain sockets or Windows named pipes.
|
||||
|
||||
use crate::{Request, Response};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const MAX_MESSAGE: u64 = 1024 * 1024;
|
||||
|
||||
fn answer(line: &str, handle: &dyn Fn(Request) -> Response) -> Response {
|
||||
match serde_json::from_str::<Request>(line) {
|
||||
Ok(request) if request.version == crate::PROTOCOL_VERSION && (!request.diff || request.targets.len() == 2) => {
|
||||
handle(request)
|
||||
}
|
||||
Ok(_) => Response::Error { message: "unsupported or invalid CLI request".into() },
|
||||
Err(e) => Response::Error { message: format!("invalid request: {e}") },
|
||||
}
|
||||
}
|
||||
|
||||
/// The socket of the app with bundle identifier `identifier`. The name is a hash:
|
||||
/// socket paths are limited to about 100 bytes, and development identifiers are long.
|
||||
pub fn socket_path(identifier: &str) -> Option<PathBuf> {
|
||||
if cfg!(windows) {
|
||||
let user = dirs::data_local_dir()?;
|
||||
return Some(PathBuf::from(format!(
|
||||
r"\\.\pipe\openidea-{:016x}",
|
||||
fnv1a(&format!("{}:{identifier}", user.display()))
|
||||
)));
|
||||
}
|
||||
let dir = if cfg!(target_os = "macos") { dirs::cache_dir() } else { dirs::runtime_dir().or_else(dirs::cache_dir) };
|
||||
Some(dir?.join(format!("openidea-{:016x}.sock", fnv1a(identifier))))
|
||||
}
|
||||
|
||||
fn fnv1a(s: &str) -> u64 {
|
||||
s.bytes().fold(0xcbf29ce484222325, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
mod imp {
|
||||
use super::*;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::os::unix::fs::{FileTypeExt, PermissionsExt};
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
|
||||
pub fn send(socket: &Path, request: &Request) -> io::Result<Response> {
|
||||
let stream = UnixStream::connect(socket)?;
|
||||
stream.set_read_timeout(Some(TIMEOUT))?;
|
||||
stream.set_write_timeout(Some(TIMEOUT))?;
|
||||
write_line(&stream, request)?;
|
||||
let mut reply = String::new();
|
||||
BufReader::new((&stream).take(MAX_MESSAGE)).read_line(&mut reply)?;
|
||||
Ok(serde_json::from_str(&reply)?)
|
||||
}
|
||||
|
||||
pub fn serve(socket: &Path, handle: impl Fn(Request) -> Response + Send + 'static) -> io::Result<()> {
|
||||
let listener = bind(socket)?;
|
||||
std::thread::Builder::new().name("cli-socket".into()).spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
if let Err(e) = serve_one(&stream, &handle) {
|
||||
tracing::warn!("cli request failed: {e}");
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Binds `socket`, replacing a stale file no one answers on.
|
||||
fn bind(socket: &Path) -> io::Result<UnixListener> {
|
||||
if let Some(dir) = socket.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let listener = match UnixListener::bind(socket) {
|
||||
Err(e) if e.kind() == io::ErrorKind::AddrInUse => {
|
||||
if !std::fs::symlink_metadata(socket)?.file_type().is_socket() || UnixStream::connect(socket).is_ok() {
|
||||
return Err(e);
|
||||
}
|
||||
std::fs::remove_file(socket)?;
|
||||
UnixListener::bind(socket)?
|
||||
}
|
||||
bound => bound?,
|
||||
};
|
||||
std::fs::set_permissions(socket, std::fs::Permissions::from_mode(0o600))?;
|
||||
Ok(listener)
|
||||
}
|
||||
|
||||
fn serve_one(stream: &UnixStream, handle: &dyn Fn(Request) -> Response) -> io::Result<()> {
|
||||
stream.set_read_timeout(Some(TIMEOUT))?;
|
||||
stream.set_write_timeout(Some(TIMEOUT))?;
|
||||
let mut line = String::new();
|
||||
BufReader::new(stream.take(MAX_MESSAGE)).read_line(&mut line)?;
|
||||
write_line(stream, &answer(&line, handle))
|
||||
}
|
||||
|
||||
fn write_line(mut stream: &UnixStream, value: &impl serde::Serialize) -> io::Result<()> {
|
||||
let mut line = serde_json::to_string(value)?;
|
||||
line.push('\n');
|
||||
stream.write_all(line.as_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod imp {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::windows::named_pipe::{ClientOptions, ServerOptions};
|
||||
|
||||
fn runtime() -> io::Result<tokio::runtime::Runtime> {
|
||||
tokio::runtime::Builder::new_current_thread().enable_all().build()
|
||||
}
|
||||
|
||||
pub fn send(socket: &Path, request: &Request) -> io::Result<Response> {
|
||||
runtime()?.block_on(async {
|
||||
tokio::time::timeout(TIMEOUT, async {
|
||||
let mut pipe = loop {
|
||||
match ClientOptions::new().open(socket) {
|
||||
Ok(pipe) => break pipe,
|
||||
// All existing instances are busy; wait instead of starting another app.
|
||||
Err(e) if e.raw_os_error() == Some(231) => tokio::time::sleep(Duration::from_millis(10)).await,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
};
|
||||
let mut line = serde_json::to_vec(request)?;
|
||||
line.push(b'\n');
|
||||
pipe.write_all(&line).await?;
|
||||
let mut reply = String::new();
|
||||
BufReader::new((&mut pipe).take(MAX_MESSAGE)).read_line(&mut reply).await?;
|
||||
pipe.write_all(b"\n").await?;
|
||||
Ok(serde_json::from_str(&reply)?)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "CLI request timed out"))?
|
||||
})
|
||||
}
|
||||
|
||||
pub fn serve(socket: &Path, handle: impl Fn(Request) -> Response + Send + 'static) -> io::Result<()> {
|
||||
let socket = socket.to_owned();
|
||||
let (ready, receiver) = std::sync::mpsc::sync_channel(1);
|
||||
std::thread::Builder::new().name("cli-pipe".into()).spawn(move || {
|
||||
let runtime = match runtime() {
|
||||
Ok(runtime) => runtime,
|
||||
Err(e) => {
|
||||
let _ = ready.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
runtime.block_on(async move {
|
||||
let mut pipe = match ServerOptions::new().first_pipe_instance(true).create(&socket) {
|
||||
Ok(pipe) => pipe,
|
||||
Err(e) => {
|
||||
let _ = ready.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = ready.send(Ok(()));
|
||||
loop {
|
||||
if let Err(e) = pipe.connect().await {
|
||||
tracing::warn!("CLI pipe connection failed: {e}");
|
||||
return;
|
||||
}
|
||||
// Keep an instance bound while answering, so another process cannot take the name.
|
||||
let next = match ServerOptions::new().create(&socket) {
|
||||
Ok(next) => next,
|
||||
Err(e) => {
|
||||
tracing::warn!("cannot create CLI pipe: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let reply = tokio::time::timeout(TIMEOUT, async {
|
||||
let mut line = String::new();
|
||||
BufReader::new((&mut pipe).take(MAX_MESSAGE)).read_line(&mut line).await?;
|
||||
let mut reply = serde_json::to_vec(&answer(&line, &handle))?;
|
||||
reply.push(b'\n');
|
||||
pipe.write_all(&reply).await?;
|
||||
// Wait until the client has drained the reply before closing its instance.
|
||||
pipe.read_exact(&mut [0u8; 1]).await.map(|_| ())
|
||||
})
|
||||
.await;
|
||||
if !matches!(reply, Ok(Ok(()))) {
|
||||
tracing::warn!("CLI pipe request failed: {reply:?}");
|
||||
}
|
||||
// Dropping after the write lets the client drain its reply before disconnecting.
|
||||
drop(pipe);
|
||||
pipe = next;
|
||||
}
|
||||
});
|
||||
})?;
|
||||
receiver.recv().map_err(|_| io::Error::other("CLI pipe thread stopped"))?
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends `request` to the app listening on `socket` and waits for its answer.
|
||||
pub use imp::send;
|
||||
/// Answers requests on `socket` with `handle`, on a thread of its own.
|
||||
pub use imp::serve;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn socket_names_separate_identifiers_and_stay_short() {
|
||||
let release = socket_path("ai.l0ng.openidea").unwrap();
|
||||
let dev = socket_path("ai.l0ng.openidea.dev.w158e9c5954b7bf64").unwrap();
|
||||
assert_ne!(release, dev);
|
||||
assert!(dev.as_os_str().len() < 100 || cfg!(unix));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answers_only_its_protocol_version() {
|
||||
let ok = |_: Request| Response::Ok;
|
||||
assert_eq!(answer(&serde_json::to_string(&Request::open(vec![])).unwrap(), &ok), Response::Ok);
|
||||
let old = r#"{"version":0,"targets":[]}"#;
|
||||
assert!(matches!(answer(old, &ok), Response::Error { .. }));
|
||||
assert!(matches!(answer("garbage", &ok), Response::Error { .. }));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn named_pipe_round_trip() {
|
||||
let socket = socket_path(&format!("test-{}", std::process::id())).unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
serve(&socket, move |request| {
|
||||
tx.send(request).unwrap();
|
||||
Response::Ok
|
||||
})
|
||||
.unwrap();
|
||||
for _ in 0..2 {
|
||||
let target = crate::Target { path: r"C:\项目\my file.rs".into(), line: Some(3), column: Some(2) };
|
||||
assert_eq!(send(&socket, &Request::open(vec![target.clone()])).unwrap(), Response::Ok);
|
||||
assert_eq!(rx.recv_timeout(TIMEOUT).unwrap().targets, vec![target]);
|
||||
}
|
||||
assert!(serve(&socket, |_| Response::Ok).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn request_round_trip_and_stale_socket() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let socket = dir.path().join("cli.sock");
|
||||
// A file left by an app that exited without removing it.
|
||||
drop(std::os::unix::net::UnixListener::bind(&socket).unwrap());
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
serve(&socket, move |request| {
|
||||
tx.send(request).unwrap();
|
||||
Response::Ok
|
||||
})
|
||||
.unwrap();
|
||||
let target = crate::Target { path: "/p/a.rs".into(), line: Some(3), column: None };
|
||||
assert_eq!(send(&socket, &Request::open(vec![target.clone()])).unwrap(), Response::Ok);
|
||||
assert_eq!(rx.recv().unwrap().targets, vec![target]);
|
||||
// A live server keeps its socket.
|
||||
assert_eq!(serve(&socket, |_| Response::Ok).unwrap_err().kind(), io::ErrorKind::AddrInUse);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn refuses_to_remove_an_unrelated_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let socket = dir.path().join("cli.sock");
|
||||
std::fs::write(&socket, "keep").unwrap();
|
||||
assert!(serve(&socket, |_| Response::Ok).is_err());
|
||||
assert_eq!(std::fs::read_to_string(&socket).unwrap(), "keep");
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ use std::sync::Arc;
|
||||
pub enum IdeEvent {
|
||||
/// Project window membership or selection changed.
|
||||
WindowGroupsChanged,
|
||||
/// A window entered or left full screen, itself or as a member of a full-screen window group.
|
||||
WindowFullscreenChanged,
|
||||
ProjectTrustChanged {
|
||||
root: crate::IpcPath,
|
||||
trusted: bool,
|
||||
|
||||
@@ -299,9 +299,11 @@ fn appimage_fixes() -> Vec<(OsString, Option<OsString>)> {
|
||||
const APPIMAGE_LAUNCH_VARS: [&str; 4] = ["APPDIR", "APPIMAGE", "ARGV0", "OWD"];
|
||||
|
||||
/// What to change in `vars` (an environment) so a child sees the host's environment rather
|
||||
/// than the AppImage's: every `:`-separated entry inside `$APPDIR` is dropped (a saved
|
||||
/// `<NAME>_ORIG` wins when present), variables left empty are removed, and so are the
|
||||
/// runtime's own launch variables. Nothing changes when `APPDIR` is not set.
|
||||
/// than the AppImage's: every `:`-separated entry inside `$APPDIR` — or inside *any*
|
||||
/// AppImage mount, since an in-app update restart inherits the previous mount's entries
|
||||
/// alongside the new one — is dropped (a saved `<NAME>_ORIG` wins when present),
|
||||
/// variables left empty are removed, and so are the runtime's own launch variables.
|
||||
/// Nothing changes when `APPDIR` is not set.
|
||||
fn appimage_env_fixes(vars: &[(OsString, OsString)]) -> Vec<(OsString, Option<OsString>)> {
|
||||
let get = |name: &str| vars.iter().find(|(k, _)| k == name).map(|(_, v)| v);
|
||||
let Some(appdir) =
|
||||
@@ -309,7 +311,12 @@ fn appimage_env_fixes(vars: &[(OsString, OsString)]) -> Vec<(OsString, Option<Os
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let inside = |entry: &str| entry.strip_prefix(appdir).is_some_and(|rest| rest.is_empty() || rest.starts_with('/'));
|
||||
let inside = |entry: &str| {
|
||||
let own = entry.strip_prefix(appdir).is_some_and(|rest| rest.is_empty() || rest.starts_with('/'));
|
||||
// AppImages extract to `/tmp/.mount_<name>`; an entry under another mount is
|
||||
// another image's (e.g. the pre-update one a restart inherited), never the host's.
|
||||
own || entry.starts_with("/tmp/.mount_")
|
||||
};
|
||||
let mut fixes = Vec::new();
|
||||
for (key, value) in vars {
|
||||
let (Some(name), Some(value)) = (key.to_str(), value.to_str()) else { continue };
|
||||
@@ -715,7 +722,7 @@ mod tests {
|
||||
("PATH", "/tmp/.mount_OpenIDabc/usr/bin/:/tmp/.mount_OpenIDabc/usr/sbin/:/usr/local/bin:/usr/bin"),
|
||||
("XDG_DATA_DIRS", "/tmp/.mount_OpenIDabc/usr/share:/usr/share:/usr/local/share:/usr/share"),
|
||||
("HOME", "/home/me"),
|
||||
("JAVA_HOME", "/tmp/.mount_OpenIDabcdef/jdk"),
|
||||
("JAVA_HOME", "/opt/jdk"),
|
||||
]);
|
||||
assert_eq!(
|
||||
got,
|
||||
@@ -728,6 +735,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_of_a_previous_updates_mount_are_dropped() {
|
||||
// An in-app update restart inherits the old mount's entries: a child's curl
|
||||
// must not load the old image's libraries (they can still be mounted).
|
||||
let got = fixes(&[
|
||||
("APPDIR", "/tmp/.mount_OpenIDjIlFFA"),
|
||||
("LD_LIBRARY_PATH", "/tmp/.mount_OpenIDjIlFFA/usr/lib/:/tmp/.mount_OpenIDdaNGfM/usr/lib/:/usr/local/lib"),
|
||||
("PATH", "/tmp/.mount_OpenIDjIlFFA/usr/bin/:/tmp/.mount_OpenIDdaNGfM/usr/bin/:/usr/bin"),
|
||||
("GIO_MODULE_DIR", "/tmp/.mount_OpenIDdaNGfM/usr/lib/gio/modules"),
|
||||
]);
|
||||
assert_eq!(
|
||||
got,
|
||||
[
|
||||
fix("APPDIR", None),
|
||||
fix("LD_LIBRARY_PATH", Some("/usr/local/lib")),
|
||||
fix("PATH", Some("/usr/bin")),
|
||||
fix("GIO_MODULE_DIR", None),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variables_left_empty_are_removed() {
|
||||
// AppRun prepends to an unset variable, leaving a trailing `:`; hooks set single paths.
|
||||
|
||||
@@ -850,6 +850,8 @@ pub struct JavaSettings {
|
||||
/// Project JDK home; empty = per module, the installed JDK matching the
|
||||
/// Java version it targets.
|
||||
pub jdk: String,
|
||||
/// Project Java language level (`8`, `11`, `17`, `21`, `25`); empty = SDK default.
|
||||
pub language_level: String,
|
||||
/// JDK home the Java language server (jdtls) runs on, apart from the project
|
||||
/// JDK (IDEA's "JDK for importer"); empty = the project JDK when new enough,
|
||||
/// else the first installed one that is.
|
||||
@@ -953,6 +955,98 @@ impl Default for TypeScriptSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Dependency scope for a module dependency or project library (IDEA's Modules › Dependencies).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DependencyScope {
|
||||
#[default]
|
||||
Compile,
|
||||
Test,
|
||||
Runtime,
|
||||
Provided,
|
||||
}
|
||||
|
||||
/// A project-level library (Project Structure › Libraries).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ProjectLibrary {
|
||||
pub name: String,
|
||||
/// Class archives (`.jar`) or class directories.
|
||||
pub classes: Vec<String>,
|
||||
/// Source archives (`-sources.jar`, `.zip`) or source directories.
|
||||
pub sources: Vec<String>,
|
||||
/// Documentation archives (`-javadoc.jar`) or directories.
|
||||
pub javadocs: Vec<String>,
|
||||
pub scope: DependencyScope,
|
||||
}
|
||||
|
||||
/// Dependency entry of a module (Project Structure › Modules › Dependencies).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ModuleDependency {
|
||||
/// `"library"` (references a `ProjectLibrary` by name or path) or `"module"` (references another module).
|
||||
pub kind: String,
|
||||
pub target: String,
|
||||
pub scope: DependencyScope,
|
||||
pub exported: bool,
|
||||
}
|
||||
|
||||
/// Per-module Project Structure overrides (SDK, language level, output paths, dependencies).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ModuleStructureSettings {
|
||||
/// Module SDK home override; empty = inherit Project SDK.
|
||||
pub jdk: String,
|
||||
/// Module language level override; empty = inherit Project language level.
|
||||
pub language_level: String,
|
||||
/// Module compiler output directory override; empty = inherit Project compiler output.
|
||||
pub compiler_output: String,
|
||||
/// Module test compiler output directory override; empty = default.
|
||||
pub test_compiler_output: String,
|
||||
/// Additional module dependencies.
|
||||
pub dependencies: Vec<ModuleDependency>,
|
||||
}
|
||||
|
||||
/// Framework facet attached to a module (Project Structure › Facets).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct FacetConfig {
|
||||
/// `"web"` | `"spring"` | `"jpa"`.
|
||||
pub kind: String,
|
||||
/// Module relative path (`"."` for root module).
|
||||
pub module: String,
|
||||
/// Deployment descriptor path (e.g. `src/main/webapp/WEB-INF/web.xml`, `src/main/resources/META-INF/persistence.xml`).
|
||||
pub descriptor: String,
|
||||
/// Web resource root directory for Web facets (e.g. `src/main/webapp`).
|
||||
pub web_root: String,
|
||||
}
|
||||
|
||||
/// Packaging type of a Project Structure artifact.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ArtifactKind {
|
||||
#[default]
|
||||
Jar,
|
||||
War,
|
||||
WarExploded,
|
||||
}
|
||||
|
||||
/// Packaging artifact definition (Project Structure › Artifacts).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ArtifactConfig {
|
||||
pub name: String,
|
||||
pub kind: ArtifactKind,
|
||||
/// Module relative path (`"."` for root module).
|
||||
pub module: String,
|
||||
/// Output directory (relative to project root or absolute).
|
||||
pub output_directory: String,
|
||||
/// Main class for executable JAR artifacts.
|
||||
pub main_class: String,
|
||||
/// Project library names included in the packaged artifact (`WEB-INF/lib` or JAR manifest).
|
||||
pub include_libraries: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ProjectStructureSettings {
|
||||
@@ -969,9 +1063,28 @@ pub struct ProjectStructureSettings {
|
||||
pub content_roots: Vec<String>,
|
||||
/// "Exclude files": `;`-separated name patterns (`*.log;tmp*`) excluded at any depth.
|
||||
pub exclude_files: String,
|
||||
/// Project compiler output directory (relative to project root or absolute; empty = default).
|
||||
pub compiler_output: String,
|
||||
/// Per-module SDK, language level, output paths and dependency overrides, keyed by module relative path (`"."` for root).
|
||||
pub modules: BTreeMap<String, ModuleStructureSettings>,
|
||||
/// Project-level libraries (`JAR`s / class folders / source archives).
|
||||
pub libraries: Vec<ProjectLibrary>,
|
||||
/// Module framework facets (`web`, `spring`, `jpa`).
|
||||
pub facets: Vec<FacetConfig>,
|
||||
/// Packaging artifacts (`jar`, `war`, `warExploded`).
|
||||
pub artifacts: Vec<ArtifactConfig>,
|
||||
}
|
||||
|
||||
impl ProjectStructureSettings {
|
||||
/// Overrides belong to the module's full path, never just its directory name.
|
||||
pub fn module_settings(
|
||||
&self,
|
||||
root: &std::path::Path,
|
||||
module: &std::path::Path,
|
||||
) -> Option<&ModuleStructureSettings> {
|
||||
self.modules.iter().find_map(|(key, settings)| (root.join(key) == module).then_some(settings))
|
||||
}
|
||||
|
||||
/// The "Exclude files" name patterns.
|
||||
pub fn exclude_file_patterns(&self) -> Vec<String> {
|
||||
self.exclude_files.split(';').map(str::trim).filter(|p| !p.is_empty()).map(str::to_owned).collect()
|
||||
@@ -1152,6 +1265,64 @@ mod tests {
|
||||
assert_eq!(s.file_excludes(), vec!["*.log", "tmp*"]);
|
||||
assert!(Settings::default().files.file_types.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_structure_modules_libraries_facets_and_artifacts_round_trip() {
|
||||
let old: Settings = serde_json::from_str(r#"{"projectStructure":{"sourceRoots":["src"]}}"#).unwrap();
|
||||
assert!(old.project_structure.compiler_output.is_empty());
|
||||
assert!(old.project_structure.modules.is_empty());
|
||||
assert!(old.project_structure.libraries.is_empty());
|
||||
assert!(old.project_structure.facets.is_empty());
|
||||
assert!(old.project_structure.artifacts.is_empty());
|
||||
assert!(old.languages.java.language_level.is_empty());
|
||||
|
||||
let mut settings = Settings::default();
|
||||
settings.languages.java.language_level = "21".into();
|
||||
settings.project_structure.compiler_output = "out/production".into();
|
||||
settings.project_structure.modules.insert(
|
||||
"backend".into(),
|
||||
ModuleStructureSettings {
|
||||
jdk: "/opt/jdk-21".into(),
|
||||
language_level: "21".into(),
|
||||
compiler_output: "backend/target/classes".into(),
|
||||
test_compiler_output: "backend/target/test-classes".into(),
|
||||
dependencies: vec![ModuleDependency {
|
||||
kind: "library".into(),
|
||||
target: "custom-lib".into(),
|
||||
scope: DependencyScope::Compile,
|
||||
exported: true,
|
||||
}],
|
||||
},
|
||||
);
|
||||
settings.project_structure.libraries.push(ProjectLibrary {
|
||||
name: "custom-lib".into(),
|
||||
classes: vec!["lib/custom.jar".into()],
|
||||
sources: vec!["lib/custom-sources.jar".into()],
|
||||
javadocs: Vec::new(),
|
||||
scope: DependencyScope::Compile,
|
||||
});
|
||||
settings.project_structure.facets.push(FacetConfig {
|
||||
kind: "web".into(),
|
||||
module: "backend".into(),
|
||||
descriptor: "src/main/webapp/WEB-INF/web.xml".into(),
|
||||
web_root: "src/main/webapp".into(),
|
||||
});
|
||||
settings.project_structure.artifacts.push(ArtifactConfig {
|
||||
name: "backend:war exploded".into(),
|
||||
kind: ArtifactKind::WarExploded,
|
||||
module: "backend".into(),
|
||||
output_directory: "out/artifacts/backend_war_exploded".into(),
|
||||
main_class: String::new(),
|
||||
include_libraries: vec!["custom-lib".into()],
|
||||
});
|
||||
|
||||
let value = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(value["languages"]["java"]["languageLevel"], "21");
|
||||
assert_eq!(value["projectStructure"]["compilerOutput"], "out/production");
|
||||
assert_eq!(value["projectStructure"]["artifacts"][0]["kind"], "warExploded");
|
||||
let restored: Settings = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(restored, settings);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -113,6 +113,8 @@ pub struct CommitSettings {
|
||||
pub body_limit: u32,
|
||||
pub show_right_margin: bool,
|
||||
pub wrap_at_margin: bool,
|
||||
/// Plugin before-commit checks turned off (`<plugin id>/<check id>`); the rest run.
|
||||
pub disabled_plugin_checks: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for CommitSettings {
|
||||
@@ -132,6 +134,7 @@ impl Default for CommitSettings {
|
||||
body_limit: 72,
|
||||
show_right_margin: true,
|
||||
wrap_at_margin: false,
|
||||
disabled_plugin_checks: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,6 +308,13 @@ pub fn by_risk(caps: &[Capability]) -> Vec<(Risk, Vec<Capability>)> {
|
||||
|
||||
/// Resolves a `process:` program to an absolute path once, at grant time:
|
||||
/// absolute names are taken as is, others are looked up in `path_var`.
|
||||
/// The directory is canonicalized but the file name kept as found: `cargo`,
|
||||
/// `rustc` and friends are often links to a multicall shim (rustup, asdf,
|
||||
/// mise) that picks the tool by the name it was invoked under — resolving the
|
||||
/// link itself would start the shim with the tool's arguments. The grant
|
||||
/// still pins the target's content: a hash read through a link reads the file
|
||||
/// it points to, so relinking or replacing either still fails the
|
||||
/// approved-file check.
|
||||
pub fn resolve_program(name: &str, path_var: Option<&OsStr>) -> Result<PathBuf, String> {
|
||||
let candidate = Path::new(name);
|
||||
if candidate.is_absolute() {
|
||||
@@ -325,15 +332,21 @@ pub fn resolve_program(name: &str, path_var: Option<&OsStr>) -> Result<PathBuf,
|
||||
for ext in &exts {
|
||||
let p = dir.join(format!("{name}{ext}"));
|
||||
if is_executable(&p) {
|
||||
return Ok(dunce_canonical(&p));
|
||||
return Ok(canonical_dir(&p));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(format!("{name} was not found on PATH"))
|
||||
}
|
||||
|
||||
fn dunce_canonical(p: &Path) -> PathBuf {
|
||||
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
|
||||
/// `p` with its parent canonicalized but the file name as found: PATH
|
||||
/// entries like `/var` → `/private/var` (macOS) or `~` links collapse to
|
||||
/// their real directory, while a linked program (`cargo` → rustup) keeps
|
||||
/// its link name so the shim still dispatches on argv[0].
|
||||
fn canonical_dir(p: &Path) -> PathBuf {
|
||||
let Some(dir) = p.parent() else { return p.to_path_buf() };
|
||||
let Some(name) = p.file_name() else { return p.to_path_buf() };
|
||||
std::fs::canonicalize(dir).map(|d| d.join(name)).unwrap_or_else(|_| p.to_path_buf())
|
||||
}
|
||||
|
||||
fn is_executable(p: &Path) -> bool {
|
||||
@@ -474,6 +487,42 @@ mod tests {
|
||||
assert!(l.check_binary(&r.subject, &cap).unwrap_err().contains("changed"));
|
||||
}
|
||||
|
||||
/// A program found as a link stays the link: multicall shims (rustup,
|
||||
/// asdf, mise) pick the tool by the name they were invoked under, so
|
||||
/// `Command::new` must see the link. The hash still covers the target —
|
||||
/// reading through the link reads it — so replacing either still trips
|
||||
/// the approved-file check.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn linked_programs_resolve_to_the_link() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let bin_dir = tmp.path().join("bin");
|
||||
let real_dir = tmp.path().join("real");
|
||||
std::fs::create_dir(&bin_dir).unwrap();
|
||||
std::fs::create_dir(&real_dir).unwrap();
|
||||
let target = real_dir.join("shim");
|
||||
std::fs::write(&target, "#!/bin/sh\necho v1\n").unwrap();
|
||||
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
std::os::unix::fs::symlink(&target, bin_dir.join("cargo")).unwrap();
|
||||
|
||||
let path_var = std::env::join_paths([bin_dir.clone()]).unwrap();
|
||||
let resolved = resolve_program("cargo", Some(&path_var)).unwrap();
|
||||
// Directory canonicalized, file name kept — still the link.
|
||||
let want = std::fs::canonicalize(&bin_dir).unwrap().join("cargo");
|
||||
assert_eq!(resolved, want);
|
||||
assert!(resolved.symlink_metadata().unwrap().file_type().is_symlink());
|
||||
|
||||
let mut l = Ledger::in_memory();
|
||||
let r = req("1.0.0", "h", &["process:cargo"], &[]);
|
||||
let cap: Capability = "process:cargo".parse().unwrap();
|
||||
let bin = BinaryGrant { path: resolved.clone(), hash: file_hash(&resolved).unwrap() };
|
||||
l.confirm(&r, &[], [(cap.to_string(), bin)].into());
|
||||
assert_eq!(l.check_binary(&r.subject, &cap).unwrap(), resolved);
|
||||
std::fs::write(&target, "#!/bin/sh\necho evil\n").unwrap();
|
||||
assert!(l.check_binary(&r.subject, &cap).unwrap_err().contains("changed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hashes_and_grouping() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -31,6 +31,6 @@ pub use manifest::{
|
||||
Manifest, check_relative, engine_compatible, validate_item_id, validate_plugin_id, validate_point_id,
|
||||
};
|
||||
pub use point::{Commit, Disposable, ExtensionPoint, MAX_PACKAGE_FILE, Package, Placement, Prepared, decode};
|
||||
pub use registry::{ContributionInfo, ExtensionRegistry, PreparedSet};
|
||||
pub use registry::{ContributionInfo, ExtensionRegistry, LOCALES_DIR, LOCALES_POINT, PreparedSet};
|
||||
pub use ui::{UiContribution, UiDecl, UiPoint, UiStore, to_json};
|
||||
pub use when::validate_when;
|
||||
@@ -9,6 +9,11 @@ use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The point behind a package's `locales/<tag>.json` catalogs: it has no
|
||||
/// `[[contributes.locales]]` table, the directory declares it.
|
||||
pub const LOCALES_POINT: &str = "locales";
|
||||
pub const LOCALES_DIR: &str = "locales";
|
||||
|
||||
/// A contribution as the plugin details page lists it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -127,6 +132,20 @@ impl ExtensionRegistry {
|
||||
set.items.push((point.id(), prepared));
|
||||
}
|
||||
}
|
||||
if let Some(point) = self.point(LOCALES_POINT)
|
||||
&& !pkg.manifest.contributes.contains_key(LOCALES_POINT)
|
||||
&& pkg.dir.join(LOCALES_DIR).is_dir()
|
||||
{
|
||||
let prepared = point
|
||||
.prepare(pkg, toml::Value::Table(Default::default()))
|
||||
.map_err(|e| format!("{LOCALES_DIR}: {}", strip_prefix(&e, LOCALES_POINT)))?;
|
||||
for f in &prepared.files {
|
||||
if !set.files.contains(f) {
|
||||
set.files.push(f.clone());
|
||||
}
|
||||
}
|
||||
set.items.push((point.id(), prepared));
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ ide-vfs.workspace = true
|
||||
regex-syntax.workspace = true
|
||||
ide-workspace.workspace = true
|
||||
ide-language.workspace = true
|
||||
blake3.workspace = true
|
||||
ide-index.workspace = true
|
||||
ide-syntax.workspace = true
|
||||
ide-search.workspace = true
|
||||
|
||||
@@ -28,7 +28,7 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
|
||||
let settings = settings.clone();
|
||||
config = host
|
||||
.blocking(move || {
|
||||
ide_sdk::go::debug_environment(&root, &settings, &mut config.env);
|
||||
ide_sdk::go::configure_environment(&root, &settings, &mut config.env);
|
||||
ide_sdk::go::apply_to_debug(
|
||||
&mut config.env,
|
||||
&mut config.extra_json,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
//! Repository version lookups for manifest inspections and completion.
|
||||
use crate::{Host, rpc::Dispatcher};
|
||||
use ide_core::{CancellationToken, DocId, IdeError, IdeResult, RequestId};
|
||||
use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo};
|
||||
use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo, go};
|
||||
use std::collections::BTreeMap;
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
struct CratesIo;
|
||||
impl DependencyVersionSource for CratesIo {
|
||||
@@ -21,6 +25,76 @@ impl DependencyVersionSource for CratesIo {
|
||||
}
|
||||
}
|
||||
|
||||
struct GoModules {
|
||||
root: PathBuf,
|
||||
env: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl DependencyVersionSource for GoModules {
|
||||
fn id(&self) -> &'static str {
|
||||
"go-modules"
|
||||
}
|
||||
|
||||
fn cache_key(&self, package: &str) -> Option<String> {
|
||||
let mut hash = DefaultHasher::new();
|
||||
(&self.root, &self.env, package).hash(&mut hash);
|
||||
Some(format!("{}-{:016x}", self.id(), hash.finish()))
|
||||
}
|
||||
|
||||
fn fetch(&self, package: &str, cancel: &CancellationToken) -> IdeResult<Vec<String>> {
|
||||
let search =
|
||||
self.env.get("PATH").map(std::ffi::OsString::from).unwrap_or_else(ide_core::tool_path::search_path);
|
||||
let go = ide_core::tool_path::find_command("go", &search, &self.root)
|
||||
.ok_or_else(|| IdeError::unavailable("Go SDK is unavailable"))?;
|
||||
// A partially edited go.mod is often invalid. Query outside it while
|
||||
// keeping the SDK and proxy/private module configuration.
|
||||
let dir = tempfile::tempdir()?;
|
||||
let mut cmd = Command::new(go);
|
||||
cmd.current_dir(dir.path())
|
||||
.env("PATH", search)
|
||||
.envs(&self.env)
|
||||
.env("GO111MODULE", "on")
|
||||
.env("GOFLAGS", "")
|
||||
.env("GOWORK", "off")
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.args(["list", "-m", "-versions", "-f", "{{range .Versions}}{{println .}}{{end}}", "--", package]);
|
||||
let query = cancel.child_token();
|
||||
// Fetches run on Host::blocking's Tokio pool, which keeps a runtime current.
|
||||
let deadline = tokio::spawn({
|
||||
let query = query.clone();
|
||||
async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
|
||||
query.cancel();
|
||||
}
|
||||
});
|
||||
let output = ide_tools::run_streamed(cmd, &query, &|_| {});
|
||||
deadline.abort();
|
||||
Ok(go::listed_versions(&output?))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
#[path = "dependencies/go_tests.rs"]
|
||||
mod go_tests;
|
||||
|
||||
fn go_source(project: &crate::Project, config_dir: &Path, path: &Path) -> IdeResult<GoModules> {
|
||||
let member = ide_workspace::workspace::Workspace::load(&project.root)?.and_then(|workspace| {
|
||||
workspace
|
||||
.projects
|
||||
.into_iter()
|
||||
.filter(|root| path.starts_with(root))
|
||||
.max_by_key(|root| root.components().count())
|
||||
});
|
||||
let settings = match &member {
|
||||
Some(root) => ide_workspace::SettingsStore::load(config_dir, Some(root)).effective(),
|
||||
None => project.settings.effective(),
|
||||
};
|
||||
let root = member.unwrap_or_else(|| project.root.clone());
|
||||
let mut env = BTreeMap::new();
|
||||
ide_sdk::go::configure_environment(&root, &settings, &mut env);
|
||||
Ok(GoModules { root, env })
|
||||
}
|
||||
|
||||
/// `offset` limits a completion request to its version string; inspections omit it.
|
||||
pub async fn dependency_versions(
|
||||
host: &Host,
|
||||
@@ -31,11 +105,24 @@ pub async fn dependency_versions(
|
||||
) -> IdeResult<Vec<DependencyVersions>> {
|
||||
let project = host.project(window)?;
|
||||
let cache = host.dependency_versions.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let config_dir = host.config_dir.clone();
|
||||
let cancel = host.begin_project_read_request(window, request);
|
||||
let result = host
|
||||
.blocking(move || {
|
||||
let snap = project.documents.snapshot(doc)?;
|
||||
if snap.large || snap.read_only || snap.path.file_name().is_none_or(|n| n != "Cargo.toml") {
|
||||
if snap.large || snap.read_only {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if snap.path.file_name().is_some_and(|name| name == "go.mod") {
|
||||
let Some(dependency) = offset.and_then(|offset| go::dependency_at(&snap.text, offset)) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
project.require_trust()?;
|
||||
let source = go_source(&project, &config_dir, snap.path.as_ref())?;
|
||||
let versions = cache.versions(&source, &dependency.package, &cancel)?;
|
||||
return Ok(vec![DependencyVersions { dependency, versions, update: None }]);
|
||||
}
|
||||
if snap.path.file_name().is_none_or(|name| name != "Cargo.toml") {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
use super::*;
|
||||
use ide_workspace::dependencies::VersionCache;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn source(dir: &Path, script: &str) -> GoModules {
|
||||
let bin = dir.join("bin");
|
||||
std::fs::create_dir_all(&bin).unwrap();
|
||||
let go = bin.join("go");
|
||||
std::fs::write(&go, format!("#!/bin/sh\n{script}\n")).unwrap();
|
||||
std::fs::set_permissions(&go, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
GoModules {
|
||||
root: dir.to_owned(),
|
||||
env: BTreeMap::from([
|
||||
("PATH".into(), bin.to_string_lossy().into_owned()),
|
||||
("PROJECT_ROOT".into(), dir.to_string_lossy().into_owned()),
|
||||
("GOPROXY".into(), "https://user:[email protected]".into()),
|
||||
("GOPRIVATE".into(), "private.example/token".into()),
|
||||
("GOWORK".into(), "broken.work".into()),
|
||||
("GOFLAGS".into(), "-mod=vendor".into()),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn go_query_is_isolated_and_persists_only_hashed_configuration_and_versions() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let source = source(
|
||||
dir.path(),
|
||||
r#"set -eu
|
||||
[ "$PWD" != "$PROJECT_ROOT" ]
|
||||
[ "$GOPROXY" = 'https://user:[email protected]' ]
|
||||
[ "$GOPRIVATE" = 'private.example/token' ]
|
||||
[ "$GOWORK" = off ]
|
||||
[ "$GOFLAGS" = '' ]
|
||||
[ "$GO111MODULE" = on ]
|
||||
[ "$GIT_TERMINAL_PROMPT" = 0 ]
|
||||
[ "$1 $2 $3 $4" = 'list -m -versions -f' ]
|
||||
[ "$5" = '{{range .Versions}}{{println .}}{{end}}' ]
|
||||
[ "$6 $7" = '-- example.com/Org/Lib/v2' ]
|
||||
printf 'v2.9.0\nv2.10.0\n'"#,
|
||||
);
|
||||
let key = source.cache_key("example.com/Org/Lib/v2").unwrap();
|
||||
assert_ne!(key, source.cache_key("example.com/org/lib/v2").unwrap());
|
||||
let mut other = GoModules { root: source.root.join("member"), env: source.env.clone() };
|
||||
assert_ne!(key, other.cache_key("example.com/Org/Lib/v2").unwrap());
|
||||
other.root = source.root.clone();
|
||||
other.env.insert("GOPROXY".into(), "https://other.example".into());
|
||||
assert_ne!(key, other.cache_key("example.com/Org/Lib/v2").unwrap());
|
||||
let cache_dir = dir.path().join("cache");
|
||||
let disk = cache_dir.clone();
|
||||
let versions = tokio::task::spawn_blocking(move || {
|
||||
VersionCache::new(cache_dir).versions(&source, "example.com/Org/Lib/v2", &CancellationToken::new())
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(versions, ["v2.10.0", "v2.9.0"]);
|
||||
let entries: Vec<_> = std::fs::read_dir(disk).unwrap().map(Result::unwrap).collect();
|
||||
assert_eq!(entries.len(), 1, "the hashed key passes the cache filename filter");
|
||||
assert_eq!(entries[0].file_name().to_str().unwrap(), format!("{key}.json"));
|
||||
let cached = std::fs::read_to_string(entries[0].path()).unwrap();
|
||||
for sensitive in ["secret", "proxy.example", "private.example", "token", "PROJECT_ROOT"] {
|
||||
assert!(!cached.contains(sensitive), "configuration must not be persisted: {sensitive}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn failed_go_queries_are_silent_and_not_persisted() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let source = source(dir.path(), "echo 'private module or offline proxy' >&2\nexit 1");
|
||||
let cache_dir = dir.path().join("cache");
|
||||
let disk = cache_dir.clone();
|
||||
let versions = tokio::task::spawn_blocking(move || {
|
||||
VersionCache::new(cache_dir).versions(&source, "private.example/module", &CancellationToken::new())
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(versions.is_empty());
|
||||
assert!(!disk.exists());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn stalled_go_queries_obey_the_deadline_and_parent_cancellation() {
|
||||
for cancel_parent in [false, true] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let source = source(dir.path(), "exec /bin/sleep 60");
|
||||
let cancel = CancellationToken::new();
|
||||
let query = cancel.clone();
|
||||
let started = Instant::now();
|
||||
let work = tokio::task::spawn_blocking(move || {
|
||||
VersionCache::new(source.root.join("cache")).versions(&source, "example.com/stalled", &query)
|
||||
});
|
||||
if cancel_parent {
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
cancel.cancel();
|
||||
assert!(work.await.unwrap().is_err());
|
||||
assert!(started.elapsed() < Duration::from_secs(3));
|
||||
} else {
|
||||
assert!(work.await.unwrap().unwrap().is_empty());
|
||||
assert!(started.elapsed() >= Duration::from_secs(10));
|
||||
assert!(started.elapsed() < Duration::from_secs(15));
|
||||
assert!(!cancel.is_cancelled(), "a query deadline must not cancel its caller");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,9 +91,10 @@ async fn resolve_launch(
|
||||
}
|
||||
}
|
||||
info.class_paths = class_paths;
|
||||
apply_library_classpath(&mut info, &project.root, &settings.project_structure.libraries, test);
|
||||
let compile_ms = started.elapsed().as_millis() - lsp_ms;
|
||||
// The project JDK, else the JDK jdtls compiles the module against, else one matching its Java version.
|
||||
let project_jdk = ide_sdk::apply::project_jdk(&project.root, &settings);
|
||||
// The module/project JDK, else the JDK jdtls compiles the module against, else one matching its Java version.
|
||||
let project_jdk = ide_sdk::apply::module_jdk(&project.root, &module, &settings);
|
||||
let from_jdtls = info.java_home.as_deref().map(ide_sdk::java::java_executable).filter(|p| p.is_file());
|
||||
let java = project_jdk.map(|j| ide_sdk::java::java_executable(&j.home)).or(from_jdtls).or_else(|| {
|
||||
// The build model's level includes what the module inherits from its parent POMs.
|
||||
@@ -153,6 +154,41 @@ fn entry_key(path: &str, windows: bool) -> String {
|
||||
if windows { key.to_lowercase() } else { key }
|
||||
}
|
||||
|
||||
fn apply_library_classpath(
|
||||
info: &mut JavaLaunchInfo,
|
||||
root: &std::path::Path,
|
||||
libraries: &[ide_core::settings::ProjectLibrary],
|
||||
test: bool,
|
||||
) {
|
||||
use ide_core::settings::DependencyScope;
|
||||
let entries: Vec<_> = libraries
|
||||
.iter()
|
||||
.flat_map(|lib| {
|
||||
let allowed = match lib.scope {
|
||||
DependencyScope::Compile | DependencyScope::Runtime => true,
|
||||
DependencyScope::Test | DependencyScope::Provided => test,
|
||||
};
|
||||
lib.classes.iter().filter_map(move |entry| {
|
||||
ide_sdk::apply::setting_path(root, entry).map(|path| (path.to_string_lossy().into_owned(), allowed))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// JDT may already have returned these archives on either classpath.
|
||||
for paths in [&mut info.class_paths, &mut info.module_paths] {
|
||||
paths.retain(|path| {
|
||||
let matching: Vec<_> = entries.iter().filter(|(entry, _)| same_entry(path, entry, cfg!(windows))).collect();
|
||||
matching.is_empty() || matching.iter().any(|(_, allowed)| *allowed)
|
||||
});
|
||||
}
|
||||
for (entry, allowed) in entries {
|
||||
if allowed
|
||||
&& !info.class_paths.iter().chain(&info.module_paths).any(|path| same_entry(path, &entry, cfg!(windows)))
|
||||
{
|
||||
info.class_paths.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn same_entry(a: &str, b: &str, windows: bool) -> bool {
|
||||
a == b || entry_key(a, windows) == entry_key(b, windows)
|
||||
}
|
||||
@@ -220,6 +256,36 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn library_scopes_filter_jdt_classpaths_and_module_paths() {
|
||||
use ide_core::settings::{DependencyScope, ProjectLibrary};
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let scopes =
|
||||
[DependencyScope::Compile, DependencyScope::Runtime, DependencyScope::Test, DependencyScope::Provided];
|
||||
let libraries: Vec<_> = scopes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, scope)| ProjectLibrary {
|
||||
classes: vec![format!("lib/{i}.jar")],
|
||||
scope: *scope,
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
let paths: Vec<_> =
|
||||
(0..4).map(|i| root.path().join(format!("lib/{i}.jar")).to_string_lossy().into_owned()).collect();
|
||||
for test in [false, true] {
|
||||
let mut info =
|
||||
JavaLaunchInfo { class_paths: paths.clone(), module_paths: paths.clone(), ..Default::default() };
|
||||
apply_library_classpath(&mut info, root.path(), &libraries, test);
|
||||
let expected = if test { &paths[..] } else { &paths[..2] };
|
||||
assert_eq!(info.class_paths, expected);
|
||||
assert_eq!(info.module_paths, expected);
|
||||
}
|
||||
let mut info = JavaLaunchInfo::default();
|
||||
apply_library_classpath(&mut info, root.path(), &libraries, false);
|
||||
assert_eq!(info.class_paths, &paths[..2]);
|
||||
}
|
||||
|
||||
/// JDT LS and our build model spell a module output differently on Windows; the
|
||||
/// snapshot must still replace it, or resources (MyBatis mapper XML) load twice.
|
||||
#[test]
|
||||
|
||||
+202
-22
@@ -7,6 +7,7 @@ use ide_core::error::ErrorKind;
|
||||
use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
|
||||
use ide_lsp::types::*;
|
||||
use ide_plugin::ProviderDocument;
|
||||
use std::path::Path;
|
||||
|
||||
async fn prepare_actions(
|
||||
project: std::sync::Arc<crate::Project>,
|
||||
@@ -85,7 +86,12 @@ const PLUGIN_SERVER: ServerId = ServerId(0);
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct PluginActionRef {
|
||||
plugin: String,
|
||||
data: String,
|
||||
data: Option<String>,
|
||||
/// Opened once the action is applied.
|
||||
open: Option<ide_core::IpcPath>,
|
||||
/// Listed by the plugin's `file-actions` export, which resolves it.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
file: bool,
|
||||
}
|
||||
|
||||
/// A plugin's `completion-item.kind` (wit/providers.wit lists them).
|
||||
@@ -443,7 +449,7 @@ pub async fn lsp_code_actions(
|
||||
};
|
||||
let plugin_actions: Vec<CodeAction> = plugin_actions
|
||||
.into_iter()
|
||||
.map(|(plugin, a)| plugin_code_action(&plugin, a))
|
||||
.map(|(plugin, a)| plugin_code_action(&plugin, &project.root, a))
|
||||
.filter(|a| {
|
||||
only.is_empty()
|
||||
|| a.kind.as_ref().is_some_and(|k| only.iter().any(|o| k == o || k.starts_with(&format!("{o}."))))
|
||||
@@ -459,23 +465,26 @@ pub async fn lsp_code_actions(
|
||||
prepare_actions(project, doc, version, actions).await
|
||||
}
|
||||
|
||||
fn plugin_code_action(plugin: &str, a: ide_plugin::CodeAction) -> CodeAction {
|
||||
// With `data` and no edits, the plugin computes them when the action is picked.
|
||||
let lazy = a.edits.is_empty() && a.data.is_some();
|
||||
let files: Vec<_> = a
|
||||
.edits
|
||||
.into_iter()
|
||||
.map(|(path, edits)| ide_core::FileEdit::Edit { path: path.into(), edits, version: None })
|
||||
.collect();
|
||||
let data = a
|
||||
.data
|
||||
.filter(|_| lazy)
|
||||
.and_then(|data| serde_json::to_value(PluginActionRef { plugin: plugin.to_owned(), data }).ok());
|
||||
fn plugin_code_action(plugin: &str, root: &Path, a: ide_plugin::CodeAction) -> CodeAction {
|
||||
// With `data` and nothing to apply, the plugin computes the edits when the action is picked.
|
||||
let lazy = a.edits.is_empty() && a.operations.is_empty() && a.data.is_some();
|
||||
let (files, open, disabled_reason) = match plugin_file_edits(root, &a) {
|
||||
Ok((files, open)) => (files, open, None),
|
||||
Err(reason) => (Vec::new(), None, Some(reason)),
|
||||
};
|
||||
let data = (lazy || open.is_some())
|
||||
.then(|| PluginActionRef {
|
||||
plugin: plugin.to_owned(),
|
||||
data: a.data.filter(|_| lazy),
|
||||
open: open.map(Into::into),
|
||||
file: a.file_action,
|
||||
})
|
||||
.and_then(|r| serde_json::to_value(r).ok());
|
||||
CodeAction {
|
||||
title: a.title,
|
||||
kind: a.kind,
|
||||
is_preferred: a.preferred,
|
||||
disabled_reason: None,
|
||||
disabled_reason,
|
||||
edit: (!lazy).then(|| WorkspaceEdit { files, preconditions: Vec::new() }),
|
||||
command: None,
|
||||
data,
|
||||
@@ -483,6 +492,53 @@ fn plugin_code_action(plugin: &str, a: ide_plugin::CodeAction) -> CodeAction {
|
||||
}
|
||||
}
|
||||
|
||||
/// The action's file operations, then its text edits, as one workspace edit's files, and the
|
||||
/// file to open. An action with file operations or an `open` target stays inside the project
|
||||
/// root as a whole: a path outside it disables the action.
|
||||
fn plugin_file_edits(
|
||||
root: &Path,
|
||||
a: &ide_plugin::CodeAction,
|
||||
) -> Result<(Vec<ide_core::FileEdit>, Option<std::path::PathBuf>), String> {
|
||||
use ide_core::FileEdit;
|
||||
use ide_plugin::FileOperation;
|
||||
let confine = !a.operations.is_empty() || a.open.is_some();
|
||||
let confined = |path: &Path| ide_plugin::resolve_new_in_root(root, &path.to_string_lossy());
|
||||
let mut files = Vec::new();
|
||||
let mut created_with_text = Vec::new();
|
||||
for operation in &a.operations {
|
||||
match operation {
|
||||
FileOperation::Create { path, text, overwrite } => {
|
||||
let path = confined(path)?;
|
||||
files.push(FileEdit::Create { path: path.clone().into(), overwrite: *overwrite });
|
||||
if !text.is_empty() {
|
||||
let start = Range::point(Pos::new(0, 0));
|
||||
files.push(FileEdit::Edit {
|
||||
path: path.clone().into(),
|
||||
edits: vec![TextEdit { range: start, new_text: text.clone() }],
|
||||
version: None,
|
||||
});
|
||||
created_with_text.push(path);
|
||||
}
|
||||
}
|
||||
FileOperation::Rename { from, to } => {
|
||||
files.push(FileEdit::Rename { from: confined(from)?.into(), to: confined(to)?.into() });
|
||||
}
|
||||
FileOperation::Delete { path } => {
|
||||
files.push(FileEdit::Delete { path: confined(path)?.into(), recursive: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (path, edits) in &a.edits {
|
||||
let path = if confine { confined(path)? } else { path.clone() };
|
||||
if created_with_text.contains(&path) {
|
||||
return Err("put the text of a created file in its `create`, not in an edit".into());
|
||||
}
|
||||
files.push(FileEdit::Edit { path: path.into(), edits: edits.clone(), version: None });
|
||||
}
|
||||
let open = a.open.as_deref().map(confined).transpose()?;
|
||||
Ok((files, open))
|
||||
}
|
||||
|
||||
/// A code action resolved by whoever offered it: its language server, or
|
||||
/// the plugin its `data` names.
|
||||
async fn resolve_action(host: &Host, window: &str, doc: DocId, action: CodeAction) -> IdeResult<CodeAction> {
|
||||
@@ -499,11 +555,15 @@ async fn resolve_action(host: &Host, window: &str, doc: DocId, action: CodeActio
|
||||
kind: action.kind.clone(),
|
||||
preferred: action.is_preferred,
|
||||
edits: Vec::new(),
|
||||
data: Some(r.data),
|
||||
operations: Vec::new(),
|
||||
open: None,
|
||||
data: r.data,
|
||||
file_action: r.file,
|
||||
};
|
||||
let plugin = r.plugin.clone();
|
||||
let resolved = blocking(move || plugins.resolve_code_action(&plugin, &d, pending)).await?;
|
||||
Ok(plugin_code_action(&r.plugin, ide_plugin::CodeAction { data: None, ..resolved }))
|
||||
let root = host.project(window)?.root.clone();
|
||||
Ok(plugin_code_action(&r.plugin, &root, ide_plugin::CodeAction { data: None, ..resolved }))
|
||||
}
|
||||
|
||||
/// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability).
|
||||
@@ -554,11 +614,15 @@ pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action
|
||||
} else {
|
||||
action
|
||||
};
|
||||
if let Some(reason) = &action.disabled_reason {
|
||||
return Err(ide_core::IdeError::invalid(reason.clone()));
|
||||
}
|
||||
if let Some(edit) = &action.edit {
|
||||
let documents = project.documents.clone();
|
||||
let edit = edit.clone();
|
||||
blocking(move || apply_edit(&documents, &edit)).await?;
|
||||
}
|
||||
open_plugin_action_target(&action, window);
|
||||
if let Some(command) = action.command
|
||||
&& !(action.edit.is_some() && is_client_follow_up(&project, doc, None, &command).await)
|
||||
{
|
||||
@@ -567,6 +631,24 @@ pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The plugin and the file its action asks to show once applied.
|
||||
fn plugin_action_open(action: &CodeAction) -> Option<(String, ide_core::IpcPath)> {
|
||||
if action.server != PLUGIN_SERVER {
|
||||
return None;
|
||||
}
|
||||
let r = action.data.clone().and_then(|d| serde_json::from_value::<PluginActionRef>(d).ok())?;
|
||||
Some((r.plugin, r.open?))
|
||||
}
|
||||
|
||||
/// Opens the file a plugin's action asked to show, now that the action is applied.
|
||||
fn open_plugin_action_target(action: &CodeAction, window: &str) {
|
||||
let (Some((plugin, path)), Some(plugins)) = (plugin_action_open(action), crate::plugins::editor_plugin_host())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
plugins.open_file(&plugin, window, path);
|
||||
}
|
||||
|
||||
/// A command after a code action's edit that no server provides: what other editors'
|
||||
/// extensions do once the edit is applied (vtsls' `_typescript.didOrganizeImports`). The edit
|
||||
/// is the action; there is nothing to run.
|
||||
@@ -677,7 +759,22 @@ pub async fn lsp_format(
|
||||
}
|
||||
|
||||
pub async fn lsp_inlay_hints(host: &Host, window: &str, doc: DocId, range: Range) -> IdeResult<Vec<InlayHint>> {
|
||||
host.project(window)?.lsp.inlay_hints(doc, range).await
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::InlayHints) else {
|
||||
return lsp.inlay_hints(doc, range).await;
|
||||
};
|
||||
let (result, hints) =
|
||||
tokio::join!(lsp.inlay_hints(doc, range), plugin_call(move || plugins.provide_inlay_hints(&d, range)));
|
||||
let hints = hints.unwrap_or_default();
|
||||
if hints.is_empty() {
|
||||
return result;
|
||||
}
|
||||
let server = match result {
|
||||
Ok(server) => server,
|
||||
Err(e) if matches!(e.kind, ErrorKind::NotFound | ErrorKind::Unavailable) => Vec::new(),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
Ok(crate::plugins::merge_inlay_hints(server, hints, range))
|
||||
}
|
||||
|
||||
pub async fn lsp_semantic_tokens(host: &Host, window: &str, doc: DocId) -> IdeResult<Option<SemanticTokens>> {
|
||||
@@ -831,6 +928,8 @@ pub async fn lsp_execute_code_action(
|
||||
return Err(ide_core::IdeError::cancelled());
|
||||
}
|
||||
let edited = action.edit.is_some();
|
||||
// A plugin's action may name a file to show once applied; the caller opens it.
|
||||
let open = plugin_action_open(&action).map(|(_, path)| path);
|
||||
if let Some(edit) = action.edit {
|
||||
let documents = project.documents.clone();
|
||||
tokio::task::spawn_blocking(move || apply_edit(&documents, &edit))
|
||||
@@ -846,7 +945,7 @@ pub async fn lsp_execute_code_action(
|
||||
.execute_action_command(doc, action.server, command, answers, cancel)
|
||||
.await
|
||||
.map(|paths| paths.into_iter().map(ide_core::IpcPath::from).collect()),
|
||||
None => Ok(Vec::new()),
|
||||
None => Ok(open.into_iter().collect()),
|
||||
}
|
||||
}
|
||||
.await;
|
||||
@@ -1010,15 +1109,19 @@ mod tests {
|
||||
kind: None,
|
||||
preferred: false,
|
||||
edits,
|
||||
operations: Vec::new(),
|
||||
open: None,
|
||||
data: data.map(str::to_owned),
|
||||
file_action: false,
|
||||
};
|
||||
let lazy = plugin_code_action("acme.x", action(Vec::new(), Some("ref")));
|
||||
let root = Path::new("/p");
|
||||
let lazy = plugin_code_action("acme.x", root, action(Vec::new(), Some("ref")));
|
||||
assert!(lazy.edit.is_none());
|
||||
let r: PluginActionRef = serde_json::from_value(lazy.data.unwrap()).unwrap();
|
||||
assert_eq!((r.plugin.as_str(), r.data.as_str()), ("acme.x", "ref"));
|
||||
assert_eq!((r.plugin.as_str(), r.data.as_deref()), ("acme.x", Some("ref")));
|
||||
// With edits, `data` is the plugin's business: applied as is.
|
||||
let edit = TextEdit { range: Range::default(), new_text: "x".into() };
|
||||
let eager = plugin_code_action("acme.x", action(vec![("/a".into(), vec![edit])], Some("ref")));
|
||||
let eager = plugin_code_action("acme.x", root, action(vec![("/a".into(), vec![edit])], Some("ref")));
|
||||
assert!(eager.edit.is_some() && eager.data.is_none());
|
||||
}
|
||||
|
||||
@@ -1095,6 +1198,83 @@ mod tests {
|
||||
assert!(without_generated_helpers(Vec::new()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plugin_action_maps_its_file_operations_before_its_edits_and_stays_in_the_project() {
|
||||
use ide_core::FileEdit;
|
||||
use ide_plugin::FileOperation;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let root = root.canonicalize().unwrap();
|
||||
let action = |operations: Vec<FileOperation>| ide_plugin::CodeAction {
|
||||
title: "Create view".into(),
|
||||
kind: Some("quickfix".into()),
|
||||
preferred: false,
|
||||
edits: vec![(root.join("a.txt"), vec![TextEdit { range: Range::default(), new_text: "x".into() }])],
|
||||
operations,
|
||||
open: Some(root.join("views/home.html")),
|
||||
data: None,
|
||||
file_action: true,
|
||||
};
|
||||
|
||||
let created = plugin_code_action(
|
||||
"acme.x",
|
||||
&root,
|
||||
action(vec![
|
||||
FileOperation::Create { path: "views/home.html".into(), text: "<h1/>".into(), overwrite: false },
|
||||
FileOperation::Rename { from: root.join("old.txt"), to: root.join("new.txt") },
|
||||
FileOperation::Delete { path: root.join("gone.txt") },
|
||||
]),
|
||||
);
|
||||
assert_eq!(created.disabled_reason, None);
|
||||
let files = created.edit.unwrap().files;
|
||||
let home = root.join("views/home.html");
|
||||
assert!(matches!(&files[0], FileEdit::Create { path, overwrite: false } if **path == home));
|
||||
assert!(
|
||||
matches!(&files[1], FileEdit::Edit { path, edits, .. } if **path == home && edits[0].new_text == "<h1/>")
|
||||
);
|
||||
assert!(matches!(&files[2], FileEdit::Rename { to, .. } if **to == root.join("new.txt")));
|
||||
assert!(matches!(&files[3], FileEdit::Delete { recursive: false, .. }));
|
||||
assert!(matches!(&files[4], FileEdit::Edit { path, .. } if **path == root.join("a.txt")));
|
||||
let r: PluginActionRef = serde_json::from_value(created.data.unwrap()).unwrap();
|
||||
assert_eq!(r.data, None);
|
||||
assert!(r.file, "resolved by the file-actions export");
|
||||
let opened = r.open.unwrap().to_path_buf();
|
||||
assert!(opened.components().eq(dunce::simplified(&home).components()), "{opened:?}");
|
||||
|
||||
let outside = tmp.path().join("evil.txt");
|
||||
let mut open_outside = action(Vec::new());
|
||||
open_outside.open = Some(outside.clone());
|
||||
assert!(plugin_code_action("acme.x", &root, open_outside).disabled_reason.is_some());
|
||||
let mut edit_outside = action(vec![FileOperation::Delete { path: root.join("gone.txt") }]);
|
||||
edit_outside.edits.push((outside, Vec::new()));
|
||||
assert!(plugin_code_action("acme.x", &root, edit_outside).disabled_reason.is_some());
|
||||
let mut edit_created =
|
||||
action(vec![FileOperation::Create { path: root.join("n.txt"), text: "x".into(), overwrite: false }]);
|
||||
edit_created.edits = vec![(root.join("n.txt"), Vec::new())];
|
||||
assert!(plugin_code_action("acme.x", &root, edit_created).disabled_reason.is_some());
|
||||
|
||||
for outside in [tmp.path().join("evil.txt"), "../evil.txt".into()] {
|
||||
for operation in [
|
||||
FileOperation::Create { path: outside.clone(), text: String::new(), overwrite: false },
|
||||
FileOperation::Rename { from: root.join("old.txt"), to: outside.clone() },
|
||||
FileOperation::Delete { path: outside.clone() },
|
||||
] {
|
||||
let rejected = plugin_code_action("acme.x", &root, action(vec![operation]));
|
||||
assert!(rejected.disabled_reason.is_some_and(|r| r.contains("outside the project root")));
|
||||
assert!(rejected.edit.unwrap().files.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
// `data` and nothing to apply: the file-actions export computes it when picked.
|
||||
let mut lazy = action(Vec::new());
|
||||
(lazy.edits, lazy.open, lazy.data) = (Vec::new(), None, Some("ref".into()));
|
||||
let lazy = plugin_code_action("acme.x", &root, lazy);
|
||||
assert!(lazy.edit.is_none());
|
||||
let r: PluginActionRef = serde_json::from_value(lazy.data.unwrap()).unwrap();
|
||||
assert_eq!((r.data.as_deref(), r.file), (Some("ref"), true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_completion_kind_the_plugin_api_documents_is_understood() {
|
||||
let wit = include_str!("../../../../wit/providers.wit");
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
//! [`HostApi`] for one plugin's code: what its imports reach in the IDE.
|
||||
//! Capabilities were checked by the runtime before any of this runs.
|
||||
|
||||
use super::{PluginContextKey, PluginEvent, PluginEventSink};
|
||||
use super::{CommitAnswer, CommitRequest, CommitSelection, PluginContextKey, PluginEvent, PluginEventSink};
|
||||
use ide_core::ipc_path::to_ipc;
|
||||
use ide_core::{CancellationToken, TextEdit};
|
||||
use ide_extension::Package;
|
||||
use ide_language::ProgramPin;
|
||||
use ide_plugin::{
|
||||
EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel, ProcessOutput, Prompt,
|
||||
RepositoryStatus, StatusItem,
|
||||
CommitChange, CommitDraft, EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel,
|
||||
ProcessOutput, Prompt, RepositoryStatus, StatusItem,
|
||||
};
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
@@ -18,11 +18,16 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Largest storage of one plugin (keys + values).
|
||||
const MAX_STORAGE: usize = 1 << 20;
|
||||
const MAX_STORAGE: usize = 64 << 20;
|
||||
const MAX_KEY: usize = 256;
|
||||
/// Most status bar items per plugin.
|
||||
const MAX_STATUS_ITEMS: usize = 8;
|
||||
const MAX_CONTEXT_KEYS: usize = 100;
|
||||
/// Most diff text of a pending commit, all files together.
|
||||
const MAX_COMMIT_DIFF: usize = 1 << 20;
|
||||
/// How long a window may take to describe its pending commit (it saves editors first).
|
||||
const COMMIT_SELECTION_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const COMMIT_MESSAGE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// (window or `None` for every window, plugin, key).
|
||||
type ContextKey = (Option<String>, String, String);
|
||||
@@ -51,6 +56,10 @@ pub trait PluginWorkspace: Send + Sync {
|
||||
/// Shows `items` as producer `source`'s diagnostics of `path` (replacing earlier ones).
|
||||
fn publish_diagnostics(&self, source: &str, path: &Path, items: Vec<ide_core::Diagnostic>)
|
||||
-> Result<(), HostError>;
|
||||
/// The encoding git content of `path` is read in (the project's file encoding).
|
||||
fn vcs_encoding(&self, _path: &Path) -> ide_vcs::TextEncoding {
|
||||
encoding_rs::UTF_8.into()
|
||||
}
|
||||
/// The user's values of `plugin`'s settings: the project's at `root`
|
||||
/// (over global), or global.
|
||||
fn plugin_settings(&self, _plugin: &str, _root: Option<&Path>) -> serde_json::Map<String, serde_json::Value> {
|
||||
@@ -150,10 +159,15 @@ struct TaskEntry {
|
||||
cancelled: bool,
|
||||
}
|
||||
|
||||
/// A question a plugin's call waits on.
|
||||
/// A question a plugin's call waits on: the user's (a prompt) or a window's.
|
||||
struct PromptEntry {
|
||||
plugin: String,
|
||||
answer: std::sync::mpsc::SyncSender<Option<Vec<String>>>,
|
||||
answer: Waiter,
|
||||
}
|
||||
|
||||
enum Waiter {
|
||||
Prompt(std::sync::mpsc::SyncSender<Option<Vec<String>>>),
|
||||
Commit(std::sync::mpsc::SyncSender<CommitAnswer>),
|
||||
}
|
||||
|
||||
/// State shared by the host and every plugin's [`PluginApi`].
|
||||
@@ -174,7 +188,7 @@ pub(super) struct Shared {
|
||||
contexts: Mutex<BTreeMap<ContextKey, String>>,
|
||||
closed_clients: Mutex<BTreeSet<String>>,
|
||||
/// plugin → kinds subscribed at run time (kept across idle reclaim).
|
||||
pub subscriptions: Mutex<HashMap<String, BTreeSet<EventKind>>>,
|
||||
pub subscriptions: Mutex<super::event_paths::Subscriptions>,
|
||||
/// plugin → files it shows problems for, withdrawn when it stops.
|
||||
diagnostics: Mutex<HashMap<String, BTreeSet<PathBuf>>>,
|
||||
pub secrets: RwLock<Arc<dyn SecretStore>>,
|
||||
@@ -182,6 +196,8 @@ pub(super) struct Shared {
|
||||
pub pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>>,
|
||||
/// Validated UI declarations (settings schemas, declared views).
|
||||
pub ui: Arc<ide_extension::UiStore>,
|
||||
/// The UI language the windows report (BCP 47).
|
||||
pub locale: RwLock<String>,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
@@ -193,6 +209,7 @@ impl Shared {
|
||||
) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
ui,
|
||||
locale: RwLock::new("en".into()),
|
||||
sink,
|
||||
workspace: RwLock::new(None),
|
||||
secrets: RwLock::new(Arc::new(FileSecretStore { dir: data_dir.clone() })),
|
||||
@@ -211,7 +228,7 @@ impl Shared {
|
||||
})
|
||||
}
|
||||
|
||||
fn workspace(&self) -> Option<Arc<dyn PluginWorkspace>> {
|
||||
pub(super) fn workspace(&self) -> Option<Arc<dyn PluginWorkspace>> {
|
||||
self.workspace.read().clone()
|
||||
}
|
||||
|
||||
@@ -314,7 +331,7 @@ impl Shared {
|
||||
let _ = workspace.publish_diagnostics(plugin, &path, Vec::new());
|
||||
}
|
||||
}
|
||||
if subscriptions && self.subscriptions.lock().remove(plugin).is_some() {
|
||||
if subscriptions && self.subscriptions.lock().remove(plugin) {
|
||||
self.sink.emit(None, PluginEvent::SubscriptionsChanged);
|
||||
}
|
||||
}
|
||||
@@ -358,8 +375,22 @@ impl Shared {
|
||||
|
||||
/// The user answered (`Some`) or dismissed (`None`) a prompt.
|
||||
pub fn answer_prompt(&self, id: u64, answer: Option<Vec<String>>) -> bool {
|
||||
let Some(prompt) = self.prompts.lock().remove(&id) else { return false };
|
||||
prompt.answer.send(answer).is_ok()
|
||||
let mut prompts = self.prompts.lock();
|
||||
if !matches!(prompts.get(&id), Some(PromptEntry { answer: Waiter::Prompt(_), .. })) {
|
||||
return false;
|
||||
}
|
||||
let Some(PromptEntry { answer: Waiter::Prompt(tx), .. }) = prompts.remove(&id) else { return false };
|
||||
tx.send(answer).is_ok()
|
||||
}
|
||||
|
||||
/// A window answered a [`CommitRequest`].
|
||||
pub fn answer_commit(&self, id: u64, answer: CommitAnswer) -> bool {
|
||||
let mut prompts = self.prompts.lock();
|
||||
if !matches!(prompts.get(&id), Some(PromptEntry { answer: Waiter::Commit(_), .. })) {
|
||||
return false;
|
||||
}
|
||||
let Some(PromptEntry { answer: Waiter::Commit(tx), .. }) = prompts.remove(&id) else { return false };
|
||||
tx.send(answer).is_ok()
|
||||
}
|
||||
|
||||
fn end_task(&self, id: u64) {
|
||||
@@ -368,11 +399,62 @@ impl Shared {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn storage_file(&self, plugin: &str) -> PathBuf {
|
||||
self.data_dir.join(plugin).join("storage.json")
|
||||
/// The plugin's stored values: one file per key, so a write only
|
||||
/// rewrites its own value.
|
||||
pub fn storage_dir(&self, plugin: &str) -> PathBuf {
|
||||
self.data_dir.join(plugin).join("storage")
|
||||
}
|
||||
}
|
||||
|
||||
fn storage_io(e: std::io::Error) -> HostError {
|
||||
HostError::Failed(format!("storage: {e}"))
|
||||
}
|
||||
|
||||
/// A key's file name: its hash, since keys may hold any character.
|
||||
fn entry_name(key: &str) -> String {
|
||||
blake3::hash(key.as_bytes()).to_hex().to_string()
|
||||
}
|
||||
|
||||
/// A stored entry: the key's length (2 bytes, little-endian), the key, then the value.
|
||||
fn decode_entry(bytes: &[u8]) -> Option<(&str, &str)> {
|
||||
let len = usize::from(u16::from_le_bytes(bytes.get(..2)?.try_into().ok()?));
|
||||
let key = std::str::from_utf8(bytes.get(2..2 + len)?).ok()?;
|
||||
let value = std::str::from_utf8(&bytes[2 + len..]).ok()?;
|
||||
Some((key, value))
|
||||
}
|
||||
|
||||
fn write_entry(dir: &Path, key: &str, value: &str) -> Result<(), HostError> {
|
||||
std::fs::create_dir_all(dir).map_err(storage_io)?;
|
||||
let file = dir.join(entry_name(key));
|
||||
let len = u16::try_from(key.len()).expect("keys are at most MAX_KEY bytes");
|
||||
let mut bytes = Vec::with_capacity(2 + key.len() + value.len());
|
||||
bytes.extend_from_slice(&len.to_le_bytes());
|
||||
bytes.extend_from_slice(key.as_bytes());
|
||||
bytes.extend_from_slice(value.as_bytes());
|
||||
let tmp = file.with_extension("tmp");
|
||||
std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, &file)).map_err(storage_io)
|
||||
}
|
||||
|
||||
/// The stored entries' files and the bytes of their keys and values.
|
||||
fn entries(dir: &Path) -> Result<impl Iterator<Item = (PathBuf, usize)>, HostError> {
|
||||
let list = match std::fs::read_dir(dir) {
|
||||
Ok(list) => list,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new().into_iter()),
|
||||
Err(e) => return Err(storage_io(e)),
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for entry in list {
|
||||
let entry = entry.map_err(storage_io)?;
|
||||
let path = entry.path();
|
||||
if path.extension().is_some() {
|
||||
continue; // a write that didn't finish
|
||||
}
|
||||
let size = entry.metadata().map_err(storage_io)?.len();
|
||||
out.push((path, usize::try_from(size).unwrap_or(usize::MAX).saturating_sub(2)));
|
||||
}
|
||||
Ok(out.into_iter())
|
||||
}
|
||||
|
||||
pub(super) struct PluginApi {
|
||||
pub pkg: Package,
|
||||
pub shared: Arc<Shared>,
|
||||
@@ -383,21 +465,24 @@ impl PluginApi {
|
||||
self.pkg.id()
|
||||
}
|
||||
|
||||
fn load_storage(&self) -> Result<BTreeMap<String, String>, HostError> {
|
||||
match std::fs::read(self.shared.storage_file(self.id())) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| HostError::Failed(format!("storage: {e}"))),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BTreeMap::new()),
|
||||
Err(e) => Err(HostError::Failed(format!("storage: {e}"))),
|
||||
/// The storage directory, after moving a `storage.json` written by an
|
||||
/// older version into it.
|
||||
fn storage(&self) -> Result<PathBuf, HostError> {
|
||||
let dir = self.shared.storage_dir(self.id());
|
||||
let legacy = self.shared.data_dir.join(self.id()).join("storage.json");
|
||||
match std::fs::read(&legacy) {
|
||||
Ok(bytes) => {
|
||||
let map: BTreeMap<String, String> =
|
||||
serde_json::from_slice(&bytes).map_err(|e| HostError::Failed(format!("storage: {e}")))?;
|
||||
for (key, value) in &map {
|
||||
write_entry(&dir, key, value)?;
|
||||
}
|
||||
std::fs::remove_file(&legacy).map_err(storage_io)?;
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(storage_io(e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_storage(&self, map: &BTreeMap<String, String>) -> Result<(), HostError> {
|
||||
let file = self.shared.storage_file(self.id());
|
||||
let bytes = serde_json::to_vec(map).map_err(|e| HostError::Failed(e.to_string()))?;
|
||||
let io = |e: std::io::Error| HostError::Failed(format!("storage: {e}"));
|
||||
std::fs::create_dir_all(file.parent().expect("has parent")).map_err(io)?;
|
||||
let tmp = file.with_extension("tmp");
|
||||
std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, &file)).map_err(io)
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// The program approved for the (already checked) `process:` grant,
|
||||
@@ -540,15 +625,113 @@ fn kill_group(child: &mut std::process::Child) {
|
||||
ide_core::process::kill_tree(child);
|
||||
}
|
||||
|
||||
impl PluginApi {
|
||||
/// Sends `request` to `client`'s window and waits for its answer, until
|
||||
/// `cancel` fires, the plugin stops or `timeout` passes.
|
||||
fn ask_commit(
|
||||
&self,
|
||||
client: &str,
|
||||
request: CommitRequest,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<CommitAnswer, HostError> {
|
||||
let id = self.shared.next_prompt.fetch_add(1, Ordering::Relaxed);
|
||||
let (answer, answers) = std::sync::mpsc::sync_channel(1);
|
||||
{
|
||||
let blocked = self.shared.prompts_blocked.lock();
|
||||
if blocked.contains(self.id()) {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
self.shared
|
||||
.prompts
|
||||
.lock()
|
||||
.insert(id, PromptEntry { plugin: self.id().to_owned(), answer: Waiter::Commit(answer) });
|
||||
}
|
||||
let request_id = id.to_string();
|
||||
self.shared.sink.emit(
|
||||
Some(client),
|
||||
PluginEvent::CommitRequested { plugin_id: self.id().to_owned(), request_id: request_id.clone(), request },
|
||||
);
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
match answers.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(answer) => return Ok(answer),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout)
|
||||
if !cancel.is_cancelled() && Instant::now() < deadline => {}
|
||||
Err(e) => {
|
||||
self.shared.prompts.lock().remove(&id);
|
||||
self.shared.sink.emit(Some(client), PluginEvent::PromptClosed { prompt_id: request_id });
|
||||
return Err(match e {
|
||||
std::sync::mpsc::RecvTimeoutError::Timeout if !cancel.is_cancelled() => {
|
||||
HostError::Failed("the window did not answer".into())
|
||||
}
|
||||
_ => HostError::Cancelled,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The changes `selection` commits, diffed repository by repository.
|
||||
pub(super) fn commit_draft(
|
||||
workspace: &dyn PluginWorkspace,
|
||||
selection: CommitSelection,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<CommitDraft, HostError> {
|
||||
let mut changes = Vec::new();
|
||||
let mut budget = MAX_COMMIT_DIFF;
|
||||
for repo in &selection.repositories {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
let Some(r) = ide_vcs::Repo::discover(&repo.root.0).map_err(failed)? else {
|
||||
return Err(HostError::NotFound(format!("{} is not in a git repository", repo.root.0.display())));
|
||||
};
|
||||
let pending = r.pending_changes(&repo.options, budget, &|path| Ok(workspace.vcs_encoding(path))).map_err(
|
||||
|e| match e.kind {
|
||||
ide_core::error::ErrorKind::InvalidInput => HostError::Invalid(e.message),
|
||||
_ => HostError::Failed(e.message),
|
||||
},
|
||||
)?;
|
||||
let root = to_ipc(r.root());
|
||||
for c in pending {
|
||||
budget -= c.diff.as_ref().map_or(0, String::len).min(budget);
|
||||
changes.push(CommitChange {
|
||||
repository: root.clone(),
|
||||
path: to_ipc(&c.path),
|
||||
old_path: c.old_path.as_deref().map(to_ipc),
|
||||
status: c.status.to_owned(),
|
||||
diff: c.diff,
|
||||
truncated: c.truncated,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(CommitDraft { revision: selection.revision, message: selection.message, amend: selection.amend, changes })
|
||||
}
|
||||
|
||||
fn failed(e: impl std::fmt::Display) -> HostError {
|
||||
HostError::Failed(e.to_string())
|
||||
}
|
||||
|
||||
impl HostApi for PluginApi {
|
||||
fn notify(&self, client: Option<&str>, level: NotificationLevel, title: String, body: String) {
|
||||
self.shared
|
||||
.sink
|
||||
.emit(client, PluginEvent::Notification { plugin_id: self.id().to_owned(), level, title, body });
|
||||
fn notify(
|
||||
&self,
|
||||
client: Option<&str>,
|
||||
level: NotificationLevel,
|
||||
title: String,
|
||||
body: String,
|
||||
actions: Vec<ide_plugin::NotificationAction>,
|
||||
) -> Result<(), HostError> {
|
||||
if !actions.is_empty() {
|
||||
let commands = self.shared.ui.items("commands", Some(self.id()));
|
||||
if let Some(a) = actions.iter().find(|a| !commands.iter().any(|c| c.key == a.command)) {
|
||||
return Err(HostError::NotFound(format!("the plugin declares no command {:?}", a.command)));
|
||||
}
|
||||
}
|
||||
let plugin_id = self.id().to_owned();
|
||||
self.shared.sink.emit(client, PluginEvent::Notification { plugin_id, level, title, body, actions });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
|
||||
@@ -563,6 +746,14 @@ impl HostApi for PluginApi {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_project(&self, client: Option<&str>, path: String, new_window: bool) -> Result<(), HostError> {
|
||||
let client = from_window(client, "open a project")?;
|
||||
self.shared
|
||||
.sink
|
||||
.emit(Some(client), PluginEvent::OpenProject { plugin_id: self.id().to_owned(), path, new_window });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ask(
|
||||
&self,
|
||||
client: Option<&str>,
|
||||
@@ -577,7 +768,10 @@ impl HostApi for PluginApi {
|
||||
if blocked.contains(self.id()) {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
self.shared.prompts.lock().insert(id, PromptEntry { plugin: self.id().to_owned(), answer });
|
||||
self.shared
|
||||
.prompts
|
||||
.lock()
|
||||
.insert(id, PromptEntry { plugin: self.id().to_owned(), answer: Waiter::Prompt(answer) });
|
||||
}
|
||||
let prompt_id = id.to_string();
|
||||
self.shared.sink.emit(
|
||||
@@ -597,6 +791,37 @@ impl HostApi for PluginApi {
|
||||
}
|
||||
}
|
||||
|
||||
fn pending_commit(
|
||||
&self,
|
||||
client: Option<&str>,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Option<CommitDraft>, HostError> {
|
||||
let client = from_window(client, "read the pending commit")?;
|
||||
let workspace = self.shared.workspace().ok_or_else(|| HostError::Failed("no workspace".into()))?;
|
||||
let root =
|
||||
workspace.project_of(client).ok_or_else(|| HostError::Invalid("the window has no project".into()))?;
|
||||
if !workspace.trusted(&root) {
|
||||
return Err(HostError::Invalid("the project is in Restricted Mode: git does not run there".into()));
|
||||
}
|
||||
let answer = self.ask_commit(client, CommitRequest::Selection, cancel, COMMIT_SELECTION_TIMEOUT)?;
|
||||
match answer {
|
||||
CommitAnswer::NoRepository => Ok(None),
|
||||
CommitAnswer::Selection { selection } => commit_draft(&*workspace, selection, cancel).map(Some),
|
||||
CommitAnswer::Done | CommitAnswer::Stale => Err(HostError::Failed("unexpected answer".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_commit_message(&self, client: Option<&str>, revision: u64, message: String) -> Result<(), HostError> {
|
||||
let client = from_window(client, "set the commit message")?;
|
||||
let request = CommitRequest::SetMessage { revision, message };
|
||||
match self.ask_commit(client, request, &CancellationToken::new(), COMMIT_MESSAGE_TIMEOUT)? {
|
||||
CommitAnswer::Done => Ok(()),
|
||||
CommitAnswer::Stale => Err(HostError::Stale),
|
||||
CommitAnswer::NoRepository => Err(HostError::Invalid("the window's project has no repository".into())),
|
||||
CommitAnswer::Selection { .. } => Err(HostError::Failed("unexpected answer".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn reveal_view(&self, client: Option<&str>, view: &str) -> Result<(), HostError> {
|
||||
let client = from_window(client, "reveal a view")?;
|
||||
let declared = self.shared.ui.items(super::ui_points::VIEWS, Some(self.id())).iter().any(|i| i.key == view);
|
||||
@@ -692,30 +917,47 @@ impl HostApi for PluginApi {
|
||||
}
|
||||
|
||||
fn storage_get(&self, key: &str) -> Result<Option<String>, HostError> {
|
||||
Ok(self.load_storage()?.get(key).cloned())
|
||||
let file = self.storage()?.join(entry_name(key));
|
||||
match std::fs::read(&file) {
|
||||
Ok(bytes) => Ok(decode_entry(&bytes).filter(|(k, _)| *k == key).map(|(_, v)| v.to_owned())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(storage_io(e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn storage_set(&self, key: &str, value: Option<String>) -> Result<(), HostError> {
|
||||
if key.is_empty() || key.len() > MAX_KEY {
|
||||
return Err(HostError::Invalid(format!("keys are 1–{MAX_KEY} bytes")));
|
||||
}
|
||||
let mut map = self.load_storage()?;
|
||||
let changed = match value {
|
||||
Some(v) => map.insert(key.to_owned(), v.clone()).as_ref() != Some(&v),
|
||||
None => map.remove(key).is_some(),
|
||||
let dir = self.storage()?;
|
||||
let file = dir.join(entry_name(key));
|
||||
let Some(value) = value else {
|
||||
return match std::fs::remove_file(&file) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(storage_io(e)),
|
||||
_ => Ok(()),
|
||||
};
|
||||
};
|
||||
if !changed {
|
||||
return Ok(());
|
||||
let others: usize = entries(&dir)?.filter(|(name, _)| *name != file).map(|(_, size)| size).sum();
|
||||
if others + key.len() + value.len() > MAX_STORAGE {
|
||||
return Err(HostError::Invalid(format!(
|
||||
"storage is limited to {} MiB (UTF-8 keys and values)",
|
||||
MAX_STORAGE >> 20
|
||||
)));
|
||||
}
|
||||
let size: usize = map.iter().map(|(k, v)| k.len() + v.len()).sum();
|
||||
if size > MAX_STORAGE {
|
||||
return Err(HostError::Invalid(format!("storage is limited to {} KiB", MAX_STORAGE >> 10)));
|
||||
}
|
||||
self.save_storage(&map)
|
||||
write_entry(&dir, key, &value)
|
||||
}
|
||||
|
||||
fn storage_keys(&self, prefix: &str) -> Result<Vec<String>, HostError> {
|
||||
Ok(self.load_storage()?.into_keys().filter(|k| k.starts_with(prefix)).collect())
|
||||
let dir = self.storage()?;
|
||||
let mut keys = Vec::new();
|
||||
for (file, _) in entries(&dir)? {
|
||||
let bytes = std::fs::read(&file).map_err(storage_io)?;
|
||||
if let Some((key, _)) = decode_entry(&bytes).filter(|(k, _)| k.starts_with(prefix)) {
|
||||
keys.push(key.to_owned());
|
||||
}
|
||||
}
|
||||
keys.sort();
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
fn vcs_status(&self, root: &Path) -> Result<Option<RepositoryStatus>, HostError> {
|
||||
@@ -924,13 +1166,22 @@ impl HostApi for PluginApi {
|
||||
})
|
||||
}
|
||||
|
||||
fn subscribe(&self, kind: EventKind, on: bool) {
|
||||
let changed = {
|
||||
let mut subs = self.shared.subscriptions.lock();
|
||||
let set = subs.entry(self.id().to_owned()).or_default();
|
||||
if on { set.insert(kind) } else { set.remove(&kind) }
|
||||
};
|
||||
if changed {
|
||||
fn subscribe(&self, kind: EventKind) {
|
||||
if self.shared.subscriptions.lock().set(self.id(), kind) {
|
||||
self.shared.sink.emit(None, PluginEvent::SubscriptionsChanged);
|
||||
}
|
||||
}
|
||||
|
||||
fn subscribe_paths(&self, kind: &str, globs: Vec<String>) -> Result<(), HostError> {
|
||||
let filter = super::event_paths::PathFilter::new(kind, globs)?;
|
||||
if self.shared.subscriptions.lock().set_paths(self.id(), kind, filter) {
|
||||
self.shared.sink.emit(None, PluginEvent::SubscriptionsChanged);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unsubscribe(&self, kind: &str) {
|
||||
if self.shared.subscriptions.lock().unsubscribe(self.id(), kind) {
|
||||
self.shared.sink.emit(None, PluginEvent::SubscriptionsChanged);
|
||||
}
|
||||
}
|
||||
@@ -1020,6 +1271,10 @@ impl HostApi for PluginApi {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn locale(&self) -> String {
|
||||
self.shared.locale.read().clone()
|
||||
}
|
||||
|
||||
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String> {
|
||||
let schema: Vec<super::ui_points::SettingSchema> = self
|
||||
.shared
|
||||
@@ -1112,6 +1367,15 @@ impl PluginWorkspace for HostWorkspace {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn vcs_encoding(&self, path: &Path) -> ide_vcs::TextEncoding {
|
||||
HostWorkspace::projects(self)
|
||||
.into_iter()
|
||||
.filter(|p| path.starts_with(&p.root))
|
||||
.max_by_key(|p| p.root.as_os_str().len())
|
||||
.and_then(|p| p.vcs_encoding(path, None).ok())
|
||||
.unwrap_or_else(|| encoding_rs::UTF_8.into())
|
||||
}
|
||||
|
||||
fn plugin_settings(&self, plugin: &str, root: Option<&Path>) -> serde_json::Map<String, serde_json::Value> {
|
||||
let Some(host) = self.0.upgrade() else { return serde_json::Map::new() };
|
||||
let settings = match root.and_then(|r| host.projects().into_iter().find(|p| p.root == r)) {
|
||||
@@ -1136,6 +1400,78 @@ mod tests {
|
||||
PluginApi { pkg: Package::new(manifest, dir.to_path_buf()), shared }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_moves_an_older_storage_json_to_one_file_per_key() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let legacy = tmp.path().join(".data/acme.lint/storage.json");
|
||||
std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
|
||||
std::fs::write(&legacy, r#"{"a":"1","b/c":"two"}"#).unwrap();
|
||||
let api = api(tmp.path(), Arc::default());
|
||||
assert_eq!(api.storage_keys("").unwrap(), ["a", "b/c"]);
|
||||
assert_eq!(api.storage_get("b/c").unwrap().as_deref(), Some("two"));
|
||||
assert!(!legacy.exists());
|
||||
api.storage_set("a", Some("3".into())).unwrap();
|
||||
api.storage_set("b/c", None).unwrap();
|
||||
assert_eq!(api.storage_keys("").unwrap(), ["a"]);
|
||||
assert_eq!(api.storage_get("a").unwrap().as_deref(), Some("3"));
|
||||
assert_eq!(api.storage_get("b/c").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_keeps_multimegabyte_indexes_after_host_recreation() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let value = "é".repeat(2 << 20);
|
||||
{
|
||||
let api = api(tmp.path(), Arc::default());
|
||||
api.storage_set("index", Some(value.clone())).unwrap();
|
||||
}
|
||||
let api = api(tmp.path(), Arc::default());
|
||||
assert_eq!(api.storage_get("index").unwrap().as_deref(), Some(value.as_str()));
|
||||
api.storage_set("index", None).unwrap();
|
||||
assert_eq!(api.storage_get("index").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_quota_counts_utf8_keys_and_values_and_preserves_data_on_failure() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let api = api(tmp.path(), Arc::default());
|
||||
let key = "index";
|
||||
let mut value = "é".repeat(((64 << 20) - key.len()) / 2);
|
||||
value.push('x');
|
||||
api.storage_set(key, Some(value.clone())).unwrap();
|
||||
|
||||
for (key, value) in [("x", String::new()), (key, format!("{value}x"))] {
|
||||
let err = api.storage_set(key, Some(value)).unwrap_err();
|
||||
assert!(matches!(err, HostError::Invalid(ref message) if message.contains("64 MiB")), "{err}");
|
||||
}
|
||||
assert_eq!(api.storage_keys("").unwrap(), [key]);
|
||||
assert_eq!(api.storage_get(key).unwrap().as_deref(), Some(value.as_str()));
|
||||
api.storage_set(key, Some("small".into())).unwrap();
|
||||
api.storage_set("other", Some("value".into())).unwrap();
|
||||
assert_eq!(api.storage_get("other").unwrap().as_deref(), Some("value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_actions_run_the_plugins_own_commands() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let api = api(tmp.path(), Arc::default());
|
||||
let _command = api.shared.ui.insert(ide_extension::UiContribution {
|
||||
plugin_id: api.id().into(),
|
||||
point: "commands".into(),
|
||||
key: "run".into(),
|
||||
decl: serde_json::json!({}),
|
||||
});
|
||||
let action = |command: &str| ide_plugin::NotificationAction {
|
||||
label: "Run".into(),
|
||||
command: command.into(),
|
||||
argument: Some("a\nb".into()),
|
||||
};
|
||||
let notify = |actions| api.notify(Some("main"), NotificationLevel::Info, "Done".into(), "".into(), actions);
|
||||
notify(vec![]).unwrap();
|
||||
notify(vec![action("run")]).unwrap();
|
||||
assert!(matches!(notify(vec![action("run"), action("other.command")]), Err(HostError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_and_search_paths_match_ipc_events() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
//! The Commit view's side of the `commit` API, as the window commands
|
||||
//! `plugin_answer_commit` / `plugin_commit_checks` serve it — the desktop for
|
||||
//! local projects, `openidea-server` for remote ones.
|
||||
|
||||
use super::{CommitSelection, PluginCommitCheckRef, PluginCommitCheckResult, PluginHost};
|
||||
use ide_core::{IdeError, IdeResult, RequestId};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Runs the plugins' before-commit checks `checks` on what `selection` commits,
|
||||
/// until request `request` is cancelled.
|
||||
pub async fn commit_checks(
|
||||
host: &crate::Host,
|
||||
plugins: Arc<PluginHost>,
|
||||
window: &str,
|
||||
selection: CommitSelection,
|
||||
checks: Vec<PluginCommitCheckRef>,
|
||||
request: RequestId,
|
||||
) -> IdeResult<Vec<PluginCommitCheckResult>> {
|
||||
host.require_trust(window)?;
|
||||
let invocation = super::views::view_invocation(host, window, None, None)?;
|
||||
let cancel = host.begin_project_read_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || plugins.commit_checks(invocation, selection, checks, &cancel))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))
|
||||
.and_then(|r| r);
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
//! `[[contributes.endpoints]]`: HTTP routes a plugin finds in a project. The UI
|
||||
//! asks for them (`plugin_endpoints`); the host caches each plugin's list per
|
||||
//! project until a file matching its globs changes, then emits `EndpointsChanged`.
|
||||
use super::{PluginEvent, PluginHost};
|
||||
use ide_core::{IdeResult, ipc_path::to_ipc};
|
||||
use ide_extension::{Capability, Package, UiDecl, decode};
|
||||
use ide_plugin::{Endpoint, HostError, Invocation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
pub(super) const POINT: &str = "endpoints";
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Decl {
|
||||
files: Vec<String>,
|
||||
}
|
||||
|
||||
pub(super) fn declaration(pkg: &Package, value: toml::Value) -> Result<UiDecl, String> {
|
||||
let d: Decl = decode(POINT, value)?;
|
||||
pkg.manifest.needs_api("contributes.endpoints", 3)?;
|
||||
if pkg.manifest.wasm.is_none() || d.files.is_empty() || d.files.len() > 64 {
|
||||
return Err("contributes.endpoints needs code and 1–64 file globs".into());
|
||||
}
|
||||
for file in &d.files {
|
||||
if file.is_empty()
|
||||
|| file.len() > 1024
|
||||
|| file.starts_with('/')
|
||||
|| file.contains('\\')
|
||||
|| file.contains(':')
|
||||
|| file.split('/').any(|p| p == "..")
|
||||
{
|
||||
return Err("endpoint file globs must be project-relative".into());
|
||||
}
|
||||
matcher(file).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let key = serde_json::to_string(&d.files).map_err(|e| e.to_string())?;
|
||||
Ok(UiDecl::new(key, "HTTP endpoints", serde_json::to_value(d).map_err(|e| e.to_string())?))
|
||||
}
|
||||
|
||||
fn matcher(glob: &str) -> Result<globset::GlobMatcher, globset::Error> {
|
||||
Ok(globset::GlobBuilder::new(glob).literal_separator(true).build()?.compile_matcher())
|
||||
}
|
||||
|
||||
/// A route as the UI gets it.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginEndpoint {
|
||||
/// `None` accepts any method.
|
||||
pub method: Option<String>,
|
||||
/// Path parameters in braces: `/photos/{id}`.
|
||||
pub path: String,
|
||||
pub framework: String,
|
||||
/// Display name, e.g. `PhotosController#show`.
|
||||
pub handler: Option<String>,
|
||||
/// Where the route is declared.
|
||||
pub location: ide_core::Location,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Endpoints {
|
||||
/// Bumped by every invalidation: a list computed across one is kept but not trusted.
|
||||
epoch: u64,
|
||||
cache: BTreeMap<(String, PathBuf), Cached>,
|
||||
}
|
||||
|
||||
struct Cached {
|
||||
list: Arc<Vec<PluginEndpoint>>,
|
||||
fresh: bool,
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// `plugin`'s routes in the project `root`: the cached list while no file
|
||||
/// matching its globs changed, else computed again. Runs plugin code, so
|
||||
/// call it on a blocking thread. A failed or invalid run keeps the last list.
|
||||
pub fn endpoints(&self, plugin: &str, root: &Path) -> IdeResult<Arc<Vec<PluginEndpoint>>> {
|
||||
let Some(workspace) = self.shared.workspace() else { return Ok(Arc::default()) };
|
||||
let open = |root: &Path| workspace.projects().iter().any(|r| r == root) && workspace.trusted(root);
|
||||
let loaded = self.get(plugin)?;
|
||||
let key = (plugin.to_owned(), root.to_path_buf());
|
||||
let epoch = {
|
||||
let mut state = self.endpoints.lock();
|
||||
state.cache.retain(|(_, r), _| open(r));
|
||||
if !open(root) || !self.endpoint_eligible(&loaded, root) {
|
||||
return Ok(Arc::default());
|
||||
}
|
||||
if let Some(cached) = state.cache.get(&key).filter(|c| c.fresh) {
|
||||
return Ok(cached.list.clone());
|
||||
}
|
||||
state.epoch
|
||||
};
|
||||
let invocation = Invocation { workspace_root: Some(root.to_path_buf()), ..Default::default() };
|
||||
let result = self
|
||||
.code_call(plugin, invocation, "endpoints", |rt, ctx| rt.endpoints(ctx, Duration::from_secs(2)))
|
||||
.and_then(|items| {
|
||||
validate(root, items, &*workspace).map_err(|e| ide_core::IdeError::invalid(e.to_string()))
|
||||
});
|
||||
let mut state = self.endpoints.lock();
|
||||
// Disabled, replaced or closed meanwhile: its routes are gone.
|
||||
let current = self.plugins.read().get(plugin).is_some_and(|p| Arc::ptr_eq(p, &loaded));
|
||||
if !current || !open(root) || !self.endpoint_eligible(&loaded, root) {
|
||||
state.cache.remove(&key);
|
||||
return Ok(Arc::default());
|
||||
}
|
||||
let fresh = state.epoch == epoch;
|
||||
let list = match result {
|
||||
Ok(list) => Arc::new(list),
|
||||
Err(e) => {
|
||||
tracing::warn!(plugin, "endpoints failed: {e}");
|
||||
state.cache.get(&key).map(|c| c.list.clone()).unwrap_or_default()
|
||||
}
|
||||
};
|
||||
state.cache.insert(key, Cached { list: list.clone(), fresh });
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
pub(super) fn clear_plugin_endpoints(&self, plugin: &str) {
|
||||
let mut state = self.endpoints.lock();
|
||||
state.epoch += 1;
|
||||
let roots: Vec<PathBuf> = state.cache.keys().filter(|(id, _)| id == plugin).map(|(_, r)| r.clone()).collect();
|
||||
state.cache.retain(|(id, _), _| id != plugin);
|
||||
drop(state);
|
||||
for root in roots {
|
||||
self.emit(None, PluginEvent::EndpointsChanged { plugin_id: plugin.to_owned(), root: to_ipc(&root).into() });
|
||||
}
|
||||
}
|
||||
|
||||
/// Called by the project's file watcher; doesn't run plugin code.
|
||||
pub fn invalidate_endpoints(&self, root: &Path, paths: &[PathBuf]) {
|
||||
let rel: Vec<&Path> = paths.iter().filter_map(|p| p.strip_prefix(root).ok()).collect();
|
||||
if rel.is_empty() {
|
||||
return;
|
||||
}
|
||||
let declarations = self.ui.items(POINT, None);
|
||||
let mut state = self.endpoints.lock();
|
||||
state.epoch += 1;
|
||||
let mut changed = Vec::new();
|
||||
for ((plugin, project), cached) in &mut state.cache {
|
||||
let matches = project == root
|
||||
&& declarations.iter().filter(|d| &d.plugin_id == plugin).any(|d| {
|
||||
d.decl["files"].as_array().is_some_and(|globs| {
|
||||
globs
|
||||
.iter()
|
||||
.filter_map(|g| matcher(g.as_str()?).ok())
|
||||
.any(|m| rel.iter().any(|p| m.is_match(p)))
|
||||
})
|
||||
});
|
||||
if matches && cached.fresh {
|
||||
cached.fresh = false;
|
||||
changed.push(plugin.clone());
|
||||
}
|
||||
}
|
||||
drop(state);
|
||||
for plugin_id in changed {
|
||||
self.emit(None, PluginEvent::EndpointsChanged { plugin_id, root: to_ipc(root).into() });
|
||||
}
|
||||
}
|
||||
|
||||
fn endpoint_eligible(&self, plugin: &super::LoadedPlugin, root: &Path) -> bool {
|
||||
plugin.status().is_live()
|
||||
&& plugin.dev_root.as_deref().is_none_or(|r| r == root)
|
||||
&& !self.ui.items(POINT, Some(plugin.id())).is_empty()
|
||||
&& {
|
||||
let meta = plugin.meta.lock();
|
||||
meta.pending.is_empty() && meta.granted.contains(&Capability::ProjectRead)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(
|
||||
root: &Path,
|
||||
items: Vec<Endpoint>,
|
||||
workspace: &dyn super::PluginWorkspace,
|
||||
) -> Result<Vec<PluginEndpoint>, HostError> {
|
||||
if items.len() > 10_000 {
|
||||
return Err(HostError::Invalid("at most 10,000 endpoints".into()));
|
||||
}
|
||||
let mut bytes = 0usize;
|
||||
let mut document_bytes = 0usize;
|
||||
let mut texts: BTreeMap<PathBuf, Vec<usize>> = BTreeMap::new();
|
||||
let mut validated = Vec::with_capacity(items.len());
|
||||
for e in items {
|
||||
bytes += e.path.len()
|
||||
+ e.method.as_ref().map_or(0, String::len)
|
||||
+ e.framework.len()
|
||||
+ e.handler.as_ref().map_or(0, String::len)
|
||||
+ e.location.path.as_os_str().len();
|
||||
if bytes > 4 << 20
|
||||
|| e.path.len() > 8192
|
||||
|| !e.path.starts_with('/')
|
||||
|| e.path.chars().any(char::is_whitespace)
|
||||
|| e.path.chars().any(char::is_control)
|
||||
|| e.method.as_ref().is_some_and(|m| {
|
||||
m.is_empty() || m.len() > 32 || !m.bytes().all(|b| b.is_ascii_uppercase() || b == b'-')
|
||||
})
|
||||
|| e.framework.is_empty()
|
||||
|| e.framework.len() > 256
|
||||
|| e.framework.chars().any(char::is_control)
|
||||
|| e.handler.as_ref().is_some_and(|h| h.len() > 1024 || h.chars().any(char::is_control))
|
||||
{
|
||||
return Err(HostError::Invalid("invalid endpoint or batch larger than 4 MiB".into()));
|
||||
}
|
||||
let path = ide_plugin::resolve_in_root(root, &e.location.path.to_string_lossy()).map_err(HostError::Invalid)?;
|
||||
if !texts.contains_key(&path) {
|
||||
let text = workspace
|
||||
.buffer_text(&path)
|
||||
.map(Ok)
|
||||
.unwrap_or_else(|| ide_plugin::read_text(&path).map_err(HostError::Invalid));
|
||||
let text = text?;
|
||||
document_bytes += text.len();
|
||||
if document_bytes > 16 << 20 {
|
||||
return Err(HostError::Invalid("endpoint declaration documents exceed 16 MiB".into()));
|
||||
}
|
||||
texts.insert(
|
||||
path.clone(),
|
||||
text.split('\n').map(|line| line.trim_end_matches('\r').encode_utf16().count()).collect(),
|
||||
);
|
||||
}
|
||||
let lines = &texts[&path];
|
||||
let range = e.location.range;
|
||||
if range.start > range.end
|
||||
|| [range.start, range.end]
|
||||
.iter()
|
||||
.any(|p| lines.get(p.line as usize).is_none_or(|len| p.col as usize > *len))
|
||||
{
|
||||
return Err(HostError::Invalid("endpoint location is outside its UTF-16 document range".into()));
|
||||
}
|
||||
validated.push(PluginEndpoint {
|
||||
method: e.method,
|
||||
path: e.path,
|
||||
framework: e.framework,
|
||||
handler: e.handler,
|
||||
location: ide_core::Location { path: to_ipc(&path).into(), range, uri: None },
|
||||
});
|
||||
}
|
||||
Ok(validated)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_core::{Pos, Range};
|
||||
struct Workspace;
|
||||
impl super::super::PluginWorkspace for Workspace {
|
||||
fn projects(&self) -> Vec<PathBuf> {
|
||||
vec![]
|
||||
}
|
||||
fn buffer_text(&self, _: &Path) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn document(&self, _: &Path) -> Option<(u64, String)> {
|
||||
None
|
||||
}
|
||||
fn trusted(&self, _: &Path) -> bool {
|
||||
true
|
||||
}
|
||||
fn project_of(&self, _: &str) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
fn apply_edits(&self, _: &Path, _: u64, _: &[ide_core::TextEdit]) -> Result<u64, HostError> {
|
||||
unreachable!()
|
||||
}
|
||||
fn publish_diagnostics(&self, _: &str, _: &Path, _: Vec<ide_core::Diagnostic>) -> Result<(), HostError> {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
fn endpoint(path: PathBuf) -> Endpoint {
|
||||
Endpoint {
|
||||
method: Some("GET".into()),
|
||||
path: "/users/{id}".into(),
|
||||
framework: "Rails".into(),
|
||||
handler: None,
|
||||
location: ide_plugin::Location { path, range: Range::new(Pos::new(0, 2), Pos::new(0, 3)) },
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn validates_entire_rooted_batch_with_utf16_positions() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
let file = root.join("routes.rb");
|
||||
std::fs::write(&file, "😀x\n").unwrap();
|
||||
let good = endpoint(file.clone());
|
||||
let items = validate(&root, vec![good.clone()], &Workspace).unwrap();
|
||||
assert_eq!(items[0].location.range.end.col, 3);
|
||||
assert!(items[0].handler.is_none());
|
||||
for bad in [
|
||||
Endpoint { method: Some("GET\nPOST".into()), ..good.clone() },
|
||||
Endpoint { path: "https://example.com".into(), ..good.clone() },
|
||||
Endpoint { path: "/bad\r\nPOST /".into(), ..good.clone() },
|
||||
Endpoint { handler: Some("bad\nhandler".into()), ..good.clone() },
|
||||
Endpoint {
|
||||
location: ide_plugin::Location {
|
||||
path: file.clone(),
|
||||
range: Range::new(Pos::new(0, 0), Pos::new(0, 4)),
|
||||
},
|
||||
..good.clone()
|
||||
},
|
||||
Endpoint {
|
||||
location: ide_plugin::Location { path: root.join("../outside"), range: Range::default() },
|
||||
..good.clone()
|
||||
},
|
||||
] {
|
||||
assert!(validate(&root, vec![good.clone(), bad], &Workspace).is_err());
|
||||
}
|
||||
assert!(validate(&root, vec![good.clone(); 10_001], &Workspace).is_err());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let outside = tempfile::NamedTempFile::new().unwrap();
|
||||
std::os::unix::fs::symlink(outside.path(), root.join("escape")).unwrap();
|
||||
assert!(validate(&root, vec![endpoint(root.join("escape"))], &Workspace).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Project-relative event filters, kept with runtime subscriptions across idle reclaim.
|
||||
use super::{LoadedPlugin, PluginHost};
|
||||
use globset::{GlobBuilder, GlobMatcher};
|
||||
use ide_plugin::{EventKind, HostError, WorkbenchEvent};
|
||||
use std::{
|
||||
collections::{BTreeSet, HashMap},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(super) struct Subscriptions {
|
||||
pub kinds: HashMap<String, BTreeSet<EventKind>>,
|
||||
pub paths: HashMap<String, HashMap<String, PathFilter>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct PathFilter {
|
||||
pub globs: Vec<String>,
|
||||
matchers: Vec<GlobMatcher>,
|
||||
}
|
||||
|
||||
impl PathFilter {
|
||||
pub fn new(kind: &str, globs: Vec<String>) -> Result<Self, HostError> {
|
||||
if !matches!(kind, "file-opened" | "file-saved") {
|
||||
return Err(HostError::Invalid("subscribe-paths supports file-opened and file-saved".into()));
|
||||
}
|
||||
if globs.len() > 64 {
|
||||
return Err(HostError::Invalid("at most 64 event path globs".into()));
|
||||
}
|
||||
let mut matchers = Vec::new();
|
||||
for input in &globs {
|
||||
let glob = input.trim_start_matches("./");
|
||||
if glob.is_empty()
|
||||
|| input.len() > 1024
|
||||
|| glob.starts_with('/')
|
||||
|| glob.contains('\\')
|
||||
|| glob.contains(':')
|
||||
|| glob.chars().any(char::is_control)
|
||||
|| glob.split('/').any(|c| c == "..")
|
||||
{
|
||||
return Err(HostError::Invalid(
|
||||
"event globs must be project-relative paths, at most 1024 bytes each".into(),
|
||||
));
|
||||
}
|
||||
matchers.push(
|
||||
GlobBuilder::new(glob)
|
||||
.literal_separator(true)
|
||||
.build()
|
||||
.map_err(|e| HostError::Invalid(format!("invalid event glob: {e}")))?
|
||||
.compile_matcher(),
|
||||
);
|
||||
}
|
||||
Ok(Self { globs, matchers })
|
||||
}
|
||||
|
||||
pub fn matches(&self, root: &Path, path: &Path) -> bool {
|
||||
let path = ide_plugin::resolve_real(path);
|
||||
let root = ide_plugin::resolve_real(root);
|
||||
path.strip_prefix(&root).is_ok_and(|relative| self.matchers.iter().any(|m| m.is_match(relative)))
|
||||
}
|
||||
}
|
||||
|
||||
impl Subscriptions {
|
||||
pub fn remove(&mut self, plugin: &str) -> bool {
|
||||
self.kinds.remove(plugin).is_some() | self.paths.remove(plugin).is_some()
|
||||
}
|
||||
/// Subscribes to every event of `kind`, dropping its path filter.
|
||||
pub fn set(&mut self, plugin: &str, kind: EventKind) -> bool {
|
||||
let removed = self.paths.get_mut(plugin).is_some_and(|filters| filters.remove(kind.name()).is_some());
|
||||
self.kinds.entry(plugin.to_owned()).or_default().insert(kind) | removed
|
||||
}
|
||||
pub fn set_paths(&mut self, plugin: &str, kind: &str, filter: PathFilter) -> bool {
|
||||
let mut changed = false;
|
||||
if let Some(kind) = EventKind::parse(kind) {
|
||||
changed = self.kinds.entry(plugin.to_owned()).or_default().insert(kind);
|
||||
}
|
||||
let previous = self.paths.entry(plugin.to_owned()).or_default().insert(kind.to_owned(), filter.clone());
|
||||
previous.is_none_or(|old| old.globs != filter.globs) | changed
|
||||
}
|
||||
pub fn unsubscribe(&mut self, plugin: &str, kind: &str) -> bool {
|
||||
let removed = self.paths.get_mut(plugin).is_some_and(|filters| filters.remove(kind).is_some());
|
||||
let off = EventKind::parse(kind)
|
||||
.is_some_and(|kind| self.kinds.get_mut(plugin).is_some_and(|kinds| kinds.remove(&kind)));
|
||||
removed | off
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
pub(super) fn filter_path_events(
|
||||
&self,
|
||||
plugin: &LoadedPlugin,
|
||||
client: Option<&str>,
|
||||
events: &[WorkbenchEvent],
|
||||
subs: &Subscriptions,
|
||||
) -> Vec<WorkbenchEvent> {
|
||||
let Some(filters) = subs.paths.get(plugin.id()) else { return events.to_vec() };
|
||||
let workspace = self.shared.workspace();
|
||||
events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
let path = match event {
|
||||
WorkbenchEvent::FileOpened(path) | WorkbenchEvent::FileSaved(path) => Path::new(path),
|
||||
_ => return true,
|
||||
};
|
||||
let Some(filter) = filters.get(event.kind().name()) else { return true };
|
||||
if !path.is_absolute() {
|
||||
return false;
|
||||
}
|
||||
let Some(workspace) = &workspace else { return false };
|
||||
let root = match client {
|
||||
Some(client) => workspace.project_of(client),
|
||||
None => {
|
||||
let real = ide_plugin::resolve_real(path);
|
||||
workspace
|
||||
.projects()
|
||||
.into_iter()
|
||||
.filter(|r| real.starts_with(ide_plugin::resolve_real(r)))
|
||||
.max_by_key(|r| r.components().count())
|
||||
}
|
||||
};
|
||||
let Some(root) = root else { return false };
|
||||
if plugin.dev_root.as_deref().is_some_and(|r| r != root) {
|
||||
return false;
|
||||
}
|
||||
filter.matches(&root, path)
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn glob_validation_is_bounded_and_project_relative() {
|
||||
for invalid in ["", "/etc/*", "../*", "a/../b", "C:/a", "a\\b", "a\nb", "["] {
|
||||
assert!(PathFilter::new("file-opened", vec![invalid.into()]).is_err(), "{invalid:?}");
|
||||
}
|
||||
assert!(PathFilter::new("editor-focused", vec!["*".into()]).is_err());
|
||||
assert!(PathFilter::new("file-saved", vec!["*".into(); 65]).is_err());
|
||||
assert!(PathFilter::new("file-saved", vec!["./".repeat(513)]).is_err());
|
||||
assert!(PathFilter::new("file-saved", vec![]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matchers_use_or_semantics_and_component_boundaries() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(root.path()).unwrap();
|
||||
let filter = PathFilter::new("file-opened", vec!["./config/*.yaml".into(), "src/**/*.rs".into()]).unwrap();
|
||||
for path in ["config/a.yaml", "src/main.rs", "src/深い/a.rs"] {
|
||||
assert!(filter.matches(&root, &root.join(path)), "{path}");
|
||||
}
|
||||
for path in ["config/nested/a.yaml", "other/a.yaml", "src/a.ts"] {
|
||||
assert!(!filter.matches(&root, &root.join(path)), "{path}");
|
||||
}
|
||||
assert!(!filter.matches(&root, &root.with_extension("other").join("config/a.yaml")));
|
||||
assert!(!PathFilter::new("file-opened", vec![]).unwrap().matches(&root, &root.join("a")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscriptions_replace_atomically_and_legacy_subscribe_clears_filters() {
|
||||
let mut subs = Subscriptions::default();
|
||||
subs.set_paths("plugin", "file-opened", PathFilter::new("file-opened", vec!["*.rs".into()]).unwrap());
|
||||
assert!(subs.kinds["plugin"].contains(&EventKind::FileOpened));
|
||||
subs.set_paths("plugin", "file-opened", PathFilter::new("file-opened", vec![]).unwrap());
|
||||
assert!(subs.paths["plugin"]["file-opened"].globs.is_empty());
|
||||
subs.set("plugin", EventKind::FileOpened);
|
||||
assert!(subs.paths["plugin"].is_empty());
|
||||
subs.set_paths("plugin", "file-saved", PathFilter::new("file-saved", vec!["*".into()]).unwrap());
|
||||
assert!(subs.unsubscribe("plugin", "file-saved"));
|
||||
assert!(subs.remove("plugin"));
|
||||
assert!(subs.paths.is_empty() && subs.kinds.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,17 @@ pub async fn plugin_navigation_index(
|
||||
result
|
||||
}
|
||||
|
||||
/// The HTTP routes `plugin_id` reports for this window's project.
|
||||
pub async fn plugin_endpoints(
|
||||
host: &crate::Host,
|
||||
plugins: Arc<PluginHost>,
|
||||
window: &str,
|
||||
plugin_id: String,
|
||||
) -> IdeResult<Vec<crate::plugins::endpoints::PluginEndpoint>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || Ok(plugins.endpoints(&plugin_id, &project.root)?.as_ref().clone())).await
|
||||
}
|
||||
|
||||
pub async fn plugin_navigation_goto(
|
||||
host: &crate::Host,
|
||||
plugins: Arc<PluginHost>,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
use ide_core::Range;
|
||||
use ide_lsp::types::{InlayHint, InlayKind};
|
||||
use ide_plugin::PluginInlayHint;
|
||||
|
||||
pub(crate) fn merge(server: Vec<InlayHint>, plugin: Vec<PluginInlayHint>, range: Range) -> Vec<InlayHint> {
|
||||
let inside = |h: &PluginInlayHint| {
|
||||
let at = (h.position.line, h.position.col);
|
||||
(range.start.line, range.start.col) <= at && at <= (range.end.line, range.end.col)
|
||||
};
|
||||
let mut out = server;
|
||||
out.extend(plugin.into_iter().filter(inside).map(|h| InlayHint {
|
||||
position: h.position,
|
||||
label: h.label,
|
||||
kind: match h.kind.as_str() {
|
||||
"type" => InlayKind::Type,
|
||||
"parameter" => InlayKind::Parameter,
|
||||
_ => InlayKind::Other,
|
||||
},
|
||||
padding_left: h.padding_left,
|
||||
padding_right: h.padding_right,
|
||||
tooltip: h.tooltip,
|
||||
parts: Vec::new(),
|
||||
}));
|
||||
out.sort_by_key(|h| (h.position.line, h.position.col));
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_core::Pos;
|
||||
|
||||
fn plugin(line: u32, col: u32, label: &str, kind: &str) -> PluginInlayHint {
|
||||
PluginInlayHint {
|
||||
position: Pos::new(line, col),
|
||||
label: label.into(),
|
||||
kind: kind.into(),
|
||||
tooltip: Some("from the plugin".into()),
|
||||
padding_left: true,
|
||||
padding_right: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn server(line: u32, col: u32, label: &str) -> InlayHint {
|
||||
InlayHint {
|
||||
position: Pos::new(line, col),
|
||||
label: label.into(),
|
||||
kind: InlayKind::Parameter,
|
||||
padding_left: false,
|
||||
padding_right: true,
|
||||
tooltip: None,
|
||||
parts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn summary(hints: &[InlayHint]) -> Vec<(u32, u32, String)> {
|
||||
hints.iter().map(|h| (h.position.line, h.position.col, h.label.clone())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_hints_join_the_servers_in_position_order() {
|
||||
let range = Range::new(Pos::new(0, 0), Pos::new(10, 0));
|
||||
let merged = merge(
|
||||
vec![server(2, 4, "name:"), server(5, 1, "id:")],
|
||||
vec![plugin(3, 9, ": string", "type"), plugin(2, 1, "GET /orders", "other")],
|
||||
range,
|
||||
);
|
||||
assert_eq!(
|
||||
summary(&merged),
|
||||
[
|
||||
(2, 1, "GET /orders".to_owned()),
|
||||
(2, 4, "name:".to_owned()),
|
||||
(3, 9, ": string".to_owned()),
|
||||
(5, 1, "id:".to_owned())
|
||||
]
|
||||
);
|
||||
let typed = merged.iter().find(|h| h.label == ": string").unwrap();
|
||||
assert!(matches!(typed.kind, InlayKind::Type));
|
||||
assert!(typed.padding_left && !typed.padding_right);
|
||||
assert_eq!(typed.tooltip.as_deref(), Some("from the plugin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_kinds_are_other_and_hints_outside_the_range_are_dropped() {
|
||||
let range = Range::new(Pos::new(2, 5), Pos::new(4, 3));
|
||||
let merged = merge(
|
||||
Vec::new(),
|
||||
vec![plugin(2, 4, "before", "type"), plugin(3, 0, "in", "route"), plugin(4, 4, "after", "type")],
|
||||
range,
|
||||
);
|
||||
assert_eq!(summary(&merged), [(3, 0, "in".to_owned())]);
|
||||
assert!(matches!(merged[0].kind, InlayKind::Other));
|
||||
}
|
||||
}
|
||||
@@ -429,6 +429,7 @@ impl PluginHost {
|
||||
level: ide_plugin::NotificationLevel::Warning,
|
||||
title: format!("{} was disabled", plugin.manifest().name),
|
||||
body: format!("The marketplace revoked this plugin: {reason}"),
|
||||
actions: Vec::new(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,12 +16,16 @@
|
||||
//! projects) and folders linked with `openidea-plugin link` (`links.json`).
|
||||
|
||||
mod api;
|
||||
pub mod commit;
|
||||
pub mod endpoints;
|
||||
mod event_paths;
|
||||
mod hover;
|
||||
mod icon_extensions;
|
||||
mod icon_patterns;
|
||||
mod icon_settings;
|
||||
mod icons;
|
||||
pub mod index_ops;
|
||||
mod inlay_hints;
|
||||
mod install;
|
||||
pub mod intellij;
|
||||
mod market;
|
||||
@@ -43,6 +47,7 @@ pub(crate) use hover::merge as merge_hover;
|
||||
pub use icons::{
|
||||
MAX_PLUGIN_ICON, map_asset as icon_theme_map_asset, plugin_icon, sanitize_svg, svg_prefix as icon_theme_svg_prefix,
|
||||
};
|
||||
pub(crate) use inlay_hints::merge as merge_inlay_hints;
|
||||
pub use market::{
|
||||
INDEX_FILE as MARKET_INDEX_FILE, MarketPlugin, OFFICIAL_INDEX as OFFICIAL_MARKET_INDEX, OFFICIAL_PUBLISHER,
|
||||
read_url as read_market_url, resolve as resolve_market_href,
|
||||
@@ -320,6 +325,7 @@ pub struct PluginHost {
|
||||
/// `[[contributes.providers]]` of enabled plugins.
|
||||
providers: Arc<providers::Providers>,
|
||||
navigation: Mutex<navigation::NavigationIndex>,
|
||||
endpoints: Mutex<endpoints::Endpoints>,
|
||||
/// Validated UI declarations of installed packages (start-up reuses them).
|
||||
index: Arc<ContributionIndex>,
|
||||
ledger: Mutex<Ledger>,
|
||||
@@ -409,6 +415,7 @@ impl PluginHost {
|
||||
run_detectors,
|
||||
providers,
|
||||
navigation: Mutex::default(),
|
||||
endpoints: Mutex::default(),
|
||||
index,
|
||||
plugins: RwLock::default(),
|
||||
state: Mutex::new(state),
|
||||
@@ -697,6 +704,15 @@ impl PluginHost {
|
||||
Ok(self.ui.assets_with_prefix(id, prefix).into_iter().map(|(k, v)| (k, v.to_string())).collect())
|
||||
}
|
||||
|
||||
/// The UI language windows run in, as `locale()` reports it to plugin code.
|
||||
pub fn set_ui_locale(&self, tag: &str) {
|
||||
*self.shared.locale.write() = tag.to_owned();
|
||||
}
|
||||
|
||||
pub fn ui_locale(&self) -> String {
|
||||
self.shared.locale.read().clone()
|
||||
}
|
||||
|
||||
pub fn set_enabled(&self, id: &str, enabled: bool) -> IdeResult<()> {
|
||||
let _guard = self.lifecycle.lock();
|
||||
let plugin = self.get(id)?;
|
||||
@@ -901,6 +917,7 @@ impl PluginHost {
|
||||
level: ide_plugin::NotificationLevel::Warning,
|
||||
title: format!("{name} update rolled back"),
|
||||
body: message.clone(),
|
||||
actions: Vec::new(),
|
||||
},
|
||||
);
|
||||
self.changed();
|
||||
@@ -1366,6 +1383,67 @@ impl PluginHost {
|
||||
prompt_id.parse().is_ok_and(|id| self.shared.answer_prompt(id, answer))
|
||||
}
|
||||
|
||||
/// A window answered a plugin's `CommitRequested`; false when the call no
|
||||
/// longer waits.
|
||||
pub fn answer_commit(&self, request_id: &str, answer: CommitAnswer) -> bool {
|
||||
request_id.parse().is_ok_and(|id| self.shared.answer_commit(id, answer))
|
||||
}
|
||||
|
||||
/// Runs the plugins' before-commit checks `checks` (`(plugin, check)`) on
|
||||
/// what `selection` commits. A check that fails or isn't available is
|
||||
/// reported with `error`, so the commit still stops for it.
|
||||
pub fn commit_checks(
|
||||
&self,
|
||||
invocation: Invocation,
|
||||
selection: CommitSelection,
|
||||
checks: Vec<PluginCommitCheckRef>,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> IdeResult<Vec<PluginCommitCheckResult>> {
|
||||
let declared = self.ui.items(ui_points::COMMIT_CHECKS, None);
|
||||
let wanted: Vec<_> = checks
|
||||
.into_iter()
|
||||
.filter_map(|c| {
|
||||
let item = declared.iter().find(|i| i.plugin_id == c.plugin_id && i.key == c.check)?;
|
||||
Some((c, item.decl["title"].as_str().unwrap_or_default().to_owned()))
|
||||
})
|
||||
.collect();
|
||||
if wanted.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let workspace = self.shared.workspace().ok_or_else(|| IdeError::unavailable("no workspace"))?;
|
||||
let draft = api::commit_draft(&*workspace, selection, cancel).map_err(|e| match e {
|
||||
HostError::Cancelled => IdeError::cancelled(),
|
||||
HostError::Invalid(m) => IdeError::invalid(m),
|
||||
e => IdeError::unavailable(e.to_string()),
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for (c, title) in wanted {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let granted = self
|
||||
.plugins
|
||||
.read()
|
||||
.get(&c.plugin_id)
|
||||
.is_some_and(|p| p.meta.lock().granted.contains(&Capability::VcsRead));
|
||||
let result = if granted {
|
||||
let (check, draft) = (c.check.clone(), draft.clone());
|
||||
self.code_call(&c.plugin_id, invocation.clone(), "checking the commit", move |rt, inv| {
|
||||
rt.commit_check(&check, draft.clone(), inv)
|
||||
})
|
||||
.map_err(|e| e.message)
|
||||
} else {
|
||||
Err(HostError::Denied(Capability::VcsRead.to_string()).to_string())
|
||||
};
|
||||
let (problems, error) = match result {
|
||||
Ok(problems) => (problems, None),
|
||||
Err(e) => (Vec::new(), Some(e)),
|
||||
};
|
||||
out.push(PluginCommitCheckResult { plugin_id: c.plugin_id, check: c.check, title, problems, error });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Kinds of workbench events some running or activatable plugin wants;
|
||||
/// the UI reports only these.
|
||||
pub fn event_kinds(&self) -> BTreeSet<EventKind> {
|
||||
@@ -1373,7 +1451,7 @@ impl PluginHost {
|
||||
let mut out = BTreeSet::new();
|
||||
for p in self.plugins.read().values() {
|
||||
for kind in EventKind::ALL {
|
||||
if self.wants(p, kind, subs.get(p.id())) {
|
||||
if self.wants(p, kind, subs.kinds.get(p.id())) {
|
||||
out.insert(kind);
|
||||
}
|
||||
}
|
||||
@@ -1406,7 +1484,8 @@ impl PluginHost {
|
||||
let mut queues = self.queues.lock();
|
||||
for p in plugins {
|
||||
let wanted: Vec<WorkbenchEvent> =
|
||||
events.iter().filter(|e| self.wants(&p, e.kind(), subs.get(p.id()))).cloned().collect();
|
||||
events.iter().filter(|e| self.wants(&p, e.kind(), subs.kinds.get(p.id()))).cloned().collect();
|
||||
let wanted = self.filter_path_events(&p, client, &wanted, &subs);
|
||||
if wanted.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -1486,6 +1565,18 @@ impl PluginHost {
|
||||
/// Returns whether the plugin was activated.
|
||||
fn deliver(&self, id: &str, client: Option<String>, events: Vec<WorkbenchEvent>) -> bool {
|
||||
let Ok(plugin) = self.get(id) else { return false };
|
||||
let subs = self.shared.subscriptions.lock().clone();
|
||||
let events: Vec<_> = self
|
||||
.filter_path_events(&plugin, client.as_deref(), &events, &subs)
|
||||
.into_iter()
|
||||
.filter(|event| {
|
||||
!matches!(event, WorkbenchEvent::FileOpened(_) | WorkbenchEvent::FileSaved(_))
|
||||
|| self.wants(&plugin, event.kind(), subs.kinds.get(id))
|
||||
})
|
||||
.collect();
|
||||
if events.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if !plugin.meta.lock().pending.is_empty() {
|
||||
return false;
|
||||
}
|
||||
@@ -2011,6 +2102,7 @@ impl PluginHost {
|
||||
}
|
||||
let Some(error) = actor.take_crash() else { return };
|
||||
self.clear_plugin_navigation(plugin.id());
|
||||
self.clear_plugin_endpoints(plugin.id());
|
||||
if error.starts_with(CANCELLED) {
|
||||
// Cancelled on request: restart on next use, no backoff.
|
||||
let mut meta = plugin.meta.lock();
|
||||
@@ -2056,6 +2148,7 @@ impl PluginHost {
|
||||
level: NotificationLevel::Warning,
|
||||
title: format!("Plugin \u{201c}{}\u{201d} crashed", plugin.manifest().name),
|
||||
body: format!("{error}\nIt restarts the next time it is needed."),
|
||||
actions: Vec::new(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -2083,6 +2176,7 @@ impl PluginHost {
|
||||
|
||||
fn stop(&self, plugin: &LoadedPlugin) {
|
||||
self.clear_plugin_navigation(plugin.id());
|
||||
self.clear_plugin_endpoints(plugin.id());
|
||||
// A call waiting on the user would keep the join below waiting.
|
||||
self.shared.block_prompts(plugin.id());
|
||||
if let Some(actor) = plugin.runtime.lock().take() {
|
||||
@@ -2095,6 +2189,7 @@ impl PluginHost {
|
||||
|
||||
fn set_failed(&self, plugin: &LoadedPlugin, error: String) {
|
||||
self.clear_plugin_navigation(plugin.id());
|
||||
self.clear_plugin_endpoints(plugin.id());
|
||||
tracing::warn!(plugin = %plugin.id(), "{error}");
|
||||
self.registry.dispose_plugin(plugin.id());
|
||||
let mut meta = plugin.meta.lock();
|
||||
@@ -2104,6 +2199,7 @@ impl PluginHost {
|
||||
|
||||
fn fail(&self, plugin: &LoadedPlugin, error: &str, client: Option<&str>) {
|
||||
self.clear_plugin_navigation(plugin.id());
|
||||
self.clear_plugin_endpoints(plugin.id());
|
||||
tracing::warn!(plugin = %plugin.id(), "disabled after failure: {error}");
|
||||
if let Some(actor) = plugin.runtime.lock().take() {
|
||||
actor.stop(false);
|
||||
@@ -2117,6 +2213,7 @@ impl PluginHost {
|
||||
level: NotificationLevel::Error,
|
||||
title: format!("Plugin \u{201c}{}\u{201d} was disabled", plugin.manifest().name),
|
||||
body: error.to_owned(),
|
||||
actions: Vec::new(),
|
||||
},
|
||||
);
|
||||
self.changed();
|
||||
|
||||
@@ -59,6 +59,7 @@ pub fn register_builtin_points(
|
||||
registry.register(Arc::new(super::navigation::IndexersPoint))?;
|
||||
registry.register(Arc::new(super::providers::ProvidersPoint::new(providers.clone())))?;
|
||||
registry.register(Arc::new(UiPoint::new(super::providers::SECRETS_POINT, ui.clone(), super::providers::secret)))?;
|
||||
registry.register(Arc::new(UiPoint::new(super::endpoints::POINT, ui.clone(), super::endpoints::declaration)))?;
|
||||
use super::ui_points as p;
|
||||
registry.register(Arc::new(UiPoint::new(p::MENUS, ui.clone(), p::menu)))?;
|
||||
registry.register(Arc::new(UiPoint::new(p::KEYMAPS, ui.clone(), p::keymap)))?;
|
||||
@@ -68,5 +69,7 @@ pub fn register_builtin_points(
|
||||
registry.register(Arc::new(UiPoint::new(p::CUSTOM_EDITORS, ui.clone(), p::custom_editor)))?;
|
||||
registry.register(Arc::new(UiPoint::new(p::FILE_ICONS, ui.clone(), p::file_icon)))?;
|
||||
registry.register(Arc::new(UiPoint::new(p::FILE_TEMPLATES, ui.clone(), p::file_template)))?;
|
||||
registry.register(Arc::new(UiPoint::new(p::COMMIT_CHECKS, ui.clone(), p::commit_check)))?;
|
||||
registry.register(Arc::new(UiPoint::new(ide_extension::LOCALES_POINT, ui.clone(), p::locales)))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -39,6 +39,7 @@ pub enum ProviderKind {
|
||||
LineMarkers,
|
||||
SemanticTokens,
|
||||
Hover,
|
||||
InlayHints,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -120,6 +121,9 @@ impl ExtensionPoint for ProvidersPoint {
|
||||
if d.kinds.contains(&ProviderKind::Hover) {
|
||||
pkg.manifest.needs_api("contributes.providers: hover", 3)?;
|
||||
}
|
||||
if d.kinds.contains(&ProviderKind::InlayHints) {
|
||||
pkg.manifest.needs_api("contributes.providers: inlay-hints", 3)?;
|
||||
}
|
||||
for l in &d.languages {
|
||||
if l != "*"
|
||||
&& (l.is_empty()
|
||||
@@ -165,11 +169,11 @@ pub(super) fn secret(_pkg: &Package, decl: toml::Value) -> Result<UiDecl, String
|
||||
|
||||
// ---- dispatch ---------------------------------------------------------------
|
||||
|
||||
use super::{LoadedPlugin, PluginHost, PluginStatus};
|
||||
use super::{LoadedPlugin, PluginEvent, PluginHost, PluginStatus};
|
||||
use ide_core::CancellationToken;
|
||||
use ide_plugin::{
|
||||
ActorError, CallError, CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, Pending, PluginHover,
|
||||
ProviderDocument, Runtime, SemanticToken,
|
||||
PluginInlayHint, ProviderDocument, Runtime, SemanticToken,
|
||||
};
|
||||
use std::sync::Weak;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -319,6 +323,12 @@ impl PluginHost {
|
||||
})
|
||||
}
|
||||
|
||||
/// Shows `path` in window `client` after a code action of `plugin_id` was applied.
|
||||
pub fn open_file(&self, plugin_id: &str, client: &str, path: ide_core::IpcPath) {
|
||||
let event = PluginEvent::OpenLocation { plugin_id: plugin_id.to_owned(), path, line: 0, column: 0 };
|
||||
self.emit(Some(client), event);
|
||||
}
|
||||
|
||||
pub fn provide_definition(&self, doc: &ProviderDocument, at: ide_core::Pos) -> Vec<Location> {
|
||||
let d = Arc::new(doc.clone());
|
||||
self.edit_calls(ProviderKind::Definition, &doc.language, move |rt, timeout| {
|
||||
@@ -357,6 +367,16 @@ impl PluginHost {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn provide_inlay_hints(&self, doc: &ProviderDocument, range: ide_core::Range) -> Vec<PluginInlayHint> {
|
||||
let d = Arc::new(doc.clone());
|
||||
self.edit_calls(ProviderKind::InlayHints, &doc.language, move |rt, timeout| {
|
||||
rt.provide_inlay_hints(&d, range, timeout)
|
||||
})
|
||||
.into_iter()
|
||||
.flat_map(|(_, hints)| hints)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Ghost text from the first plugin that has some. Not bound by the
|
||||
/// edit-path soft deadline (it may need the network) but by `cancel`:
|
||||
/// the editor cancels a request as soon as the user types on.
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
//! The commands a window needs at run time (`plugin_run_command`,
|
||||
//! `plugin_post_events`, `plugin_event_kinds`, `plugin_status_items`,
|
||||
//! `plugin_context_keys`, `plugin_cancel_task`, `plugin_answer_prompt`, `plugin_set_active_file`,
|
||||
//! `plugin_line_markers`, `plugin_view_render`, `plugin_view_event`, `plugin_service_detail`,
|
||||
//! `plugin_service_log`)
|
||||
//! `plugin_line_markers`, `plugin_set_ui_locale`, `plugin_view_render`, `plugin_view_event`,
|
||||
//! `plugin_service_detail`, `plugin_service_log`, `plugin_answer_commit`, `plugin_commit_checks`)
|
||||
//! carry the desktop commands' names and arguments, so a remote window's UI
|
||||
//! routes them here unchanged. Plugin results come back as `plugin`
|
||||
//! notifications ([`PluginEventMessage`]); secrets stay in the desktop's
|
||||
@@ -119,9 +119,12 @@ pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
| PluginEvent::OpenLocation { .. }
|
||||
| PluginEvent::CopyToClipboard { .. }
|
||||
| PluginEvent::OpenUrl { .. }
|
||||
| PluginEvent::OpenProject { .. }
|
||||
| PluginEvent::PromptRequested { .. }
|
||||
| PluginEvent::PromptClosed { .. }
|
||||
| PluginEvent::RevealView { .. }
|
||||
| PluginEvent::CommitRequested { .. }
|
||||
| PluginEvent::EndpointsChanged { .. }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -383,6 +386,14 @@ impl RemotePlugins {
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_set_ui_locale", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
alive(&h)?.set_ui_locale(&c.arg::<String>("tag")?);
|
||||
Ok(())
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_post_events", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
@@ -422,6 +433,26 @@ impl RemotePlugins {
|
||||
async move { Ok(alive(&h)?.answer_prompt(&c.arg::<String>("promptId")?, c.arg("answer")?)) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_answer_commit", move |c| {
|
||||
let h = h.clone();
|
||||
async move { Ok(alive(&h)?.answer_commit(&c.arg::<String>("requestId")?, c.arg("answer")?)) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_commit_checks", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
super::commit::commit_checks(
|
||||
&c.host,
|
||||
alive(&h)?,
|
||||
&c.window,
|
||||
c.arg("selection")?,
|
||||
c.arg("checks")?,
|
||||
c.arg("request")?,
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_set_active_file", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
@@ -537,6 +568,11 @@ impl RemotePlugins {
|
||||
super::index_ops::plugin_navigation_markers(&c.host, alive(&h)?, &c.window, c.arg("paths")?).await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_endpoints", move |c| {
|
||||
let h = h.clone();
|
||||
async move { super::index_ops::plugin_endpoints(&c.host, alive(&h)?, &c.window, c.arg("pluginId")?).await }
|
||||
});
|
||||
d.add("plugin_line_markers", |c| async move {
|
||||
crate::ops::lsp::plugin_line_markers(&c.host, &c.window, c.arg("doc")?).await
|
||||
});
|
||||
|
||||
@@ -168,11 +168,22 @@ pub enum PluginEvent {
|
||||
CommandsChanged,
|
||||
/// Cached document associations were disposed (reload, disable or failure).
|
||||
DocumentIndexesChanged,
|
||||
/// The routes `plugin_id` reports for the project `root` may have changed:
|
||||
/// ask `plugin_endpoints` again.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
EndpointsChanged { plugin_id: String, root: ide_core::IpcPath },
|
||||
/// The marketplace offers newer versions of installed plugins (announced
|
||||
/// once per set of versions).
|
||||
UpdatesAvailable { updates: Vec<PluginUpdate> },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Notification { plugin_id: String, level: NotificationLevel, title: String, body: String },
|
||||
Notification {
|
||||
plugin_id: String,
|
||||
level: NotificationLevel,
|
||||
title: String,
|
||||
body: String,
|
||||
/// Buttons running the plugin's own commands (`ui.notify`).
|
||||
actions: Vec<ide_plugin::NotificationAction>,
|
||||
},
|
||||
/// A plugin's status bar item was set (`item`) or removed.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
StatusItemChanged { plugin_id: String, id: String, item: Option<StatusItem> },
|
||||
@@ -222,6 +233,9 @@ pub enum PluginEvent {
|
||||
/// Open `url` (`http(s)://`) in the system browser.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
OpenUrl { plugin_id: String, url: String },
|
||||
/// A window command/event requests the normal File > Open flow.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
OpenProject { plugin_id: String, path: String, new_window: bool },
|
||||
/// A command waits for the user's answer: show `prompt`, then report it with
|
||||
/// `plugin_answer_prompt(prompt_id, …)`.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -232,6 +246,55 @@ pub enum PluginEvent {
|
||||
/// Bring the tool window of the plugin's view `view` to the front.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
RevealView { plugin_id: String, view: String },
|
||||
/// A command needs the window's Commit view: answer with
|
||||
/// `plugin_answer_commit(request_id, …)`. Closed like a prompt (`PromptClosed`).
|
||||
#[serde(rename_all = "camelCase")]
|
||||
CommitRequested { plugin_id: String, request_id: String, request: CommitRequest },
|
||||
}
|
||||
|
||||
/// What a plugin asks of a window's Commit view.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum CommitRequest {
|
||||
/// What Commit would commit now ([`CommitAnswer::Selection`]); unsaved
|
||||
/// editors of the included files are saved first.
|
||||
Selection,
|
||||
/// Replace the message if the draft is still at `revision`.
|
||||
SetMessage { revision: u64, message: String },
|
||||
}
|
||||
|
||||
/// A window's answer to a [`CommitRequest`].
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum CommitAnswer {
|
||||
/// The window's project has no repository.
|
||||
NoRepository,
|
||||
Selection {
|
||||
selection: CommitSelection,
|
||||
},
|
||||
/// The message was replaced.
|
||||
Done,
|
||||
/// The draft is no longer at the revision.
|
||||
Stale,
|
||||
}
|
||||
|
||||
/// What a window's Commit view would commit: per repository, the options
|
||||
/// `vcs_commit` would get.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommitSelection {
|
||||
/// Changes whenever the message, the included changes or Amend do.
|
||||
pub revision: u64,
|
||||
pub message: String,
|
||||
pub amend: bool,
|
||||
pub repositories: Vec<CommitSelectionRepo>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommitSelectionRepo {
|
||||
pub root: ide_core::IpcPath,
|
||||
pub options: ide_vcs::CommitOptions,
|
||||
}
|
||||
|
||||
/// Delivers events to the UI. `client` is the window that invoked the plugin
|
||||
@@ -300,3 +363,24 @@ pub struct LineMarkerInfo {
|
||||
/// Opened on click.
|
||||
pub target: Option<ide_core::Location>,
|
||||
}
|
||||
|
||||
/// A plugin's before-commit check to run (`[[contributes.commitChecks]]`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginCommitCheckRef {
|
||||
pub plugin_id: String,
|
||||
pub check: String,
|
||||
}
|
||||
|
||||
/// What a plugin's before-commit check found.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginCommitCheckResult {
|
||||
pub plugin_id: String,
|
||||
pub check: String,
|
||||
/// The check's declared title.
|
||||
pub title: String,
|
||||
pub problems: Vec<ide_plugin::CommitProblem>,
|
||||
/// The check could not run (the plugin failed, timed out or lacks `vcs.read`).
|
||||
pub error: Option<String>,
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
//!
|
||||
//! ```toml
|
||||
//! [[contributes.menus]]
|
||||
//! menu = "editor.context" # projectView.context, editorTab.context, tools, viewItem.context
|
||||
//! menu = "editor.context" # projectView.context, editorTab.context, tools, viewItem.context, commit.message
|
||||
//! command = "check"
|
||||
//! when = "editorLang == java"
|
||||
//! # view = "<view id>" # required for (only allowed on) viewItem.context
|
||||
@@ -42,6 +42,10 @@
|
||||
//! icon = "icons/drawing.svg"
|
||||
//! iconDark = "icons/drawing_dark.svg"
|
||||
//!
|
||||
//! [[contributes.commitChecks]]
|
||||
//! id = "conventional"
|
||||
//! title = "Conventional Commits" # Commit Options › Before Commit; `commit-checker.check` runs it
|
||||
//!
|
||||
//! [[contributes.fileTemplates]]
|
||||
//! id = "drawing"
|
||||
//! name = "Excalidraw Drawing"
|
||||
@@ -49,14 +53,14 @@
|
||||
//! path = "templates/drawing.excalidraw"
|
||||
//! ```
|
||||
|
||||
use ide_extension::{Package, UiDecl, decode, validate_item_id, validate_when};
|
||||
use ide_extension::{LOCALES_DIR, Package, UiDecl, decode, validate_item_id, validate_when};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A view whose tree shows in the Services tool window; the plugin's
|
||||
/// `service-provider` renders the selected node's pane.
|
||||
/// `detail-provider` renders the selected node's pane.
|
||||
pub const SERVICES_LOCATION: &str = "services";
|
||||
pub const MENUS: &str = "menus";
|
||||
pub const KEYMAPS: &str = "keymaps";
|
||||
@@ -66,9 +70,13 @@ pub const VIEWS: &str = "views";
|
||||
pub const CUSTOM_EDITORS: &str = "customEditors";
|
||||
pub const FILE_ICONS: &str = "fileIcons";
|
||||
pub const FILE_TEMPLATES: &str = "fileTemplates";
|
||||
pub const COMMIT_CHECKS: &str = "commitChecks";
|
||||
|
||||
const VIEW_ITEM_MENU: &str = "viewItem.context";
|
||||
const MENU_IDS: [&str; 5] = ["editor.context", "projectView.context", "editorTab.context", "tools", VIEW_ITEM_MENU];
|
||||
/// The toolbar above the commit message (Commit tool window and dialog).
|
||||
const COMMIT_MESSAGE_MENU: &str = "commit.message";
|
||||
const MENU_IDS: [&str; 6] =
|
||||
["editor.context", "projectView.context", "editorTab.context", "tools", VIEW_ITEM_MENU, COMMIT_MESSAGE_MENU];
|
||||
const MAX_TITLE: usize = 256;
|
||||
const MAX_SETTINGS: usize = 200;
|
||||
const MAX_PATTERNS: usize = 16;
|
||||
@@ -114,6 +122,10 @@ struct MenuDecl {
|
||||
when: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
view: Option<String>,
|
||||
/// Groups the plugin's entries of one context menu that name the same
|
||||
/// submenu under it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
submenu: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
@@ -122,9 +134,19 @@ pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
return Err(format!("contributes.menus: unknown menu {:?} (one of {})", d.menu, MENU_IDS.join(", ")));
|
||||
}
|
||||
declared_command(pkg, MENUS, &d.command)?;
|
||||
if d.menu == COMMIT_MESSAGE_MENU {
|
||||
pkg.manifest.needs_api(&format!("contributes.menus: {COMMIT_MESSAGE_MENU}"), 3)?;
|
||||
}
|
||||
if let Some(w) = &d.when {
|
||||
validate_when(w).map_err(|e| format!("contributes.menus: {e}"))?;
|
||||
}
|
||||
if let Some(submenu) = &d.submenu {
|
||||
if !d.menu.ends_with(".context") {
|
||||
return Err(format!("contributes.menus: `submenu` is only for context menus, not {:?}", d.menu));
|
||||
}
|
||||
title(MENUS, submenu)?;
|
||||
pkg.manifest.needs_api("contributes.menus: submenu", 3)?;
|
||||
}
|
||||
let key = match (d.menu == VIEW_ITEM_MENU, &d.view) {
|
||||
(true, None) => return Err(format!("contributes.menus: {VIEW_ITEM_MENU} needs `view`")),
|
||||
(true, Some(v)) => {
|
||||
@@ -554,6 +576,80 @@ pub(super) fn file_template(pkg: &Package, decl: toml::Value) -> Result<UiDecl,
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CommitCheckDecl {
|
||||
id: String,
|
||||
title: String,
|
||||
}
|
||||
|
||||
/// `[[contributes.commitChecks]]`: a before-commit check run by the plugin's
|
||||
/// `commit-checker` export, listed in Commit Options.
|
||||
pub(super) fn commit_check(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
let d: CommitCheckDecl = decode(COMMIT_CHECKS, decl)?;
|
||||
pkg.manifest.needs_api(&format!("contributes.{COMMIT_CHECKS}"), 3)?;
|
||||
validate_item_id(&d.id, "commit check id")?;
|
||||
title(COMMIT_CHECKS, &d.title)?;
|
||||
if pkg.manifest.wasm.is_none() {
|
||||
return Err(format!("contributes.{COMMIT_CHECKS}: needs `wasm` (the checks run in the plugin's code)"));
|
||||
}
|
||||
if !pkg.manifest.capabilities.all().any(|c| *c == ide_extension::Capability::VcsRead) {
|
||||
return Err(format!("contributes.{COMMIT_CHECKS}: needs the `vcs.read` capability (checks read the commit)"));
|
||||
}
|
||||
Ok(UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?))
|
||||
}
|
||||
|
||||
const MAX_CATALOG_ENTRIES: usize = 20_000;
|
||||
|
||||
pub fn locale_asset(tag: &str) -> String {
|
||||
format!("locale/{tag}")
|
||||
}
|
||||
|
||||
fn locale_tag_ok(tag: &str) -> bool {
|
||||
let mut parts = tag.split('-');
|
||||
let language = parts.next().unwrap_or_default();
|
||||
(2..=3).contains(&language.len())
|
||||
&& language.chars().all(|c| c.is_ascii_alphabetic())
|
||||
&& parts.all(|p| (1..=8).contains(&p.len()) && p.chars().all(|c| c.is_ascii_alphanumeric()))
|
||||
}
|
||||
|
||||
/// `locales/<tag>.json`: English source text → translation, for the plugin's own strings.
|
||||
pub(super) fn locales(pkg: &Package, _decl: toml::Value) -> Result<UiDecl, String> {
|
||||
let dir = Path::new(LOCALES_DIR);
|
||||
let mut names: Vec<String> = std::fs::read_dir(pkg.dir.join(dir))
|
||||
.map_err(|e| format!("{LOCALES_DIR}: {e}"))?
|
||||
.filter_map(|entry| entry.ok()?.file_name().into_string().ok())
|
||||
.filter(|name| name.ends_with(".json"))
|
||||
.collect();
|
||||
names.sort();
|
||||
let mut tags = Vec::new();
|
||||
let mut files = Vec::new();
|
||||
let mut assets = Vec::new();
|
||||
for name in names {
|
||||
let tag = name.trim_end_matches(".json");
|
||||
if !locale_tag_ok(tag) {
|
||||
return Err(format!("{LOCALES_DIR}/{name}: the file name must be a language tag such as fr or zh-CN"));
|
||||
}
|
||||
let file = dir.join(&name);
|
||||
let text = pkg.read_text(&file)?;
|
||||
let catalog: BTreeMap<String, Value> = serde_json::from_str(&text)
|
||||
.map_err(|e| format!("{}: not a JSON object of strings ({e})", file.display()))?;
|
||||
if catalog.len() > MAX_CATALOG_ENTRIES {
|
||||
return Err(format!("{}: more than {MAX_CATALOG_ENTRIES} entries", file.display()));
|
||||
}
|
||||
if let Some((key, _)) = catalog.iter().find(|(_, v)| !v.is_string()) {
|
||||
return Err(format!("{}: the value of {key:?} is not a string", file.display()));
|
||||
}
|
||||
assets.push((locale_asset(tag), text));
|
||||
tags.push(tag.to_owned());
|
||||
files.push(file);
|
||||
}
|
||||
let mut out = UiDecl::new("locales", tags.join(", "), serde_json::json!({ "tags": tags }));
|
||||
out.files = files;
|
||||
out.assets = assets;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -585,4 +681,41 @@ mod tests {
|
||||
assert!(parse_settings_schema(r#"{"properties": {"x": {"type": "string", "default": 3}}}"#).is_err());
|
||||
assert!(parse_settings_schema(r#"{"properties": {"x": {"type": "object"}}}"#).is_err());
|
||||
}
|
||||
|
||||
fn package_with_catalogs(files: &[(&str, &str)]) -> (tempfile::TempDir, Package) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(dir.path().join(LOCALES_DIR)).unwrap();
|
||||
for (name, text) in files {
|
||||
std::fs::write(dir.path().join(LOCALES_DIR).join(name), text).unwrap();
|
||||
}
|
||||
let src = "id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"";
|
||||
let pkg = Package::new(ide_extension::Manifest::parse(src).unwrap(), dir.path().to_path_buf());
|
||||
(dir, pkg)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalogs_become_assets_by_language_tag() {
|
||||
let (_dir, pkg) = package_with_catalogs(&[
|
||||
("fr.json", r#"{"Refresh": "Actualiser"}"#),
|
||||
("zh-CN.json", r#"{"Refresh": "刷新"}"#),
|
||||
("notes.txt", "ignored"),
|
||||
]);
|
||||
let decl = locales(&pkg, toml::Value::Table(Default::default())).unwrap();
|
||||
assert_eq!(decl.json["tags"], serde_json::json!(["fr", "zh-CN"]));
|
||||
assert_eq!(decl.assets.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(), ["locale/fr", "locale/zh-CN"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_catalogs_are_refused() {
|
||||
for (name, text) in [
|
||||
("fr.json", "{"),
|
||||
("fr.json", r#"["Refresh"]"#),
|
||||
("fr.json", r#"{"Refresh": 3}"#),
|
||||
("fr.json", r#"{"Refresh": {"a": "b"}}"#),
|
||||
("french!.json", "{}"),
|
||||
] {
|
||||
let (_dir, pkg) = package_with_catalogs(&[(name, text)]);
|
||||
assert!(locales(&pkg, toml::Value::Table(Default::default())).is_err(), "{name}: {text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,6 +193,7 @@ impl Host {
|
||||
&effective.project_structure,
|
||||
),
|
||||
);
|
||||
ide_lsp::jdtls::configure_project_structure(cfg, root, server_root, &effective.project_structure);
|
||||
let bundles: Vec<PathBuf> = ide_tools::managed_bundle(&data_dir, "java-debug")
|
||||
.into_iter()
|
||||
.chain(ide_sdk::java::usages_bundle(&data_dir))
|
||||
@@ -213,13 +214,19 @@ impl Host {
|
||||
cfg.launch_error = Some(e);
|
||||
None
|
||||
});
|
||||
ide_sdk::apply::configure_java_compiler(cfg, &data_dir, &effective.inspections);
|
||||
let lang_level = ide_sdk::apply::effective_java_language_level(root, server_root, &effective);
|
||||
ide_sdk::apply::configure_java_compiler_with_level(
|
||||
cfg,
|
||||
&data_dir,
|
||||
&effective.inspections,
|
||||
lang_level,
|
||||
);
|
||||
ide_sdk::java::configure_jdtls(
|
||||
cfg,
|
||||
server_root,
|
||||
&data_dir,
|
||||
&jdks,
|
||||
ide_sdk::apply::project_jdk(root, &effective).as_ref(),
|
||||
ide_sdk::apply::module_jdk(root, server_root, &effective).as_ref(),
|
||||
server_jdk.as_ref(),
|
||||
min_java,
|
||||
&bundles,
|
||||
@@ -347,6 +354,9 @@ impl Host {
|
||||
}
|
||||
// Atomic saves by external tools surface as `created`.
|
||||
documents.on_disk_changed(&[&changed[..], &ignored].concat());
|
||||
if let Some(plugins) = crate::plugins::editor_plugin_host() {
|
||||
plugins.invalidate_endpoints(&root, &[changed.clone(), batch.removed.clone()].concat());
|
||||
}
|
||||
// A framework's root marker (`angular.json`) created or deleted: its
|
||||
// templates gain or lose their mode. Markers in `node_modules` only
|
||||
// count here when the folder isn't excluded (it is by default).
|
||||
|
||||
@@ -196,6 +196,8 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
cmd,
|
||||
"app_info"
|
||||
| "app_build_info"
|
||||
| "app_cli_install"
|
||||
| "app_cli_uninstall"
|
||||
| "app_log_location"
|
||||
| "app_check_update"
|
||||
| "app_startup_update_check"
|
||||
@@ -300,6 +302,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "plugin_set_theme_frame"
|
||||
| "plugin_catalog"
|
||||
| "plugin_assets"
|
||||
| "plugin_set_ui_locale"
|
||||
| "plugin_market"
|
||||
| "plugin_market_url"
|
||||
| "plugin_set_market_url"
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
//! The `commit` interface and the `commit-checker` export, through the
|
||||
//! `test.commit` fixture and a scripted window answering `CommitRequested`.
|
||||
|
||||
use ide_core::ipc_path::to_ipc;
|
||||
use ide_host::plugins::*;
|
||||
use ide_plugin::{HostError, Invocation, Limits};
|
||||
use parking_lot::Mutex;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
fn fixture_dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/commit")
|
||||
}
|
||||
|
||||
fn git(dir: &Path, args: &[&str]) {
|
||||
let out = Command::new("git")
|
||||
.current_dir(dir)
|
||||
.args(["-c", "init.defaultBranch=main", "-c", "user.name=T", "-c", "[email protected]"])
|
||||
.args(args)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
|
||||
struct Workspace {
|
||||
root: PathBuf,
|
||||
trusted: AtomicBool,
|
||||
}
|
||||
|
||||
impl PluginWorkspace for Workspace {
|
||||
fn projects(&self) -> Vec<PathBuf> {
|
||||
vec![self.root.clone()]
|
||||
}
|
||||
fn buffer_text(&self, _: &Path) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn document(&self, _: &Path) -> Option<(u64, String)> {
|
||||
None
|
||||
}
|
||||
fn apply_edits(&self, _: &Path, _: u64, _: &[ide_core::TextEdit]) -> Result<u64, HostError> {
|
||||
Err(HostError::NotFound("not open".into()))
|
||||
}
|
||||
fn trusted(&self, _: &Path) -> bool {
|
||||
self.trusted.load(Ordering::Relaxed)
|
||||
}
|
||||
fn project_of(&self, _: &str) -> Option<PathBuf> {
|
||||
Some(self.root.clone())
|
||||
}
|
||||
fn publish_diagnostics(&self, _: &str, _: &Path, _: Vec<ide_core::Diagnostic>) -> Result<(), HostError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The window's Commit view: its draft, and whether the user types between
|
||||
/// the plugin reading it and writing the message.
|
||||
#[derive(Default)]
|
||||
struct Window {
|
||||
revision: u64,
|
||||
message: String,
|
||||
selection: Option<CommitSelection>,
|
||||
type_after_reading: bool,
|
||||
requests: Vec<CommitRequest>,
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
host: Arc<PluginHost>,
|
||||
window: Arc<Mutex<Window>>,
|
||||
workspace: Arc<Workspace>,
|
||||
project: PathBuf,
|
||||
_tmp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project = tmp.path().join("project");
|
||||
std::fs::create_dir_all(&project).unwrap();
|
||||
git(&project, &["init", "-q"]);
|
||||
std::fs::write(project.join("a.txt"), "one\ntwo\nthree\n").unwrap();
|
||||
git(&project, &["add", "."]);
|
||||
git(&project, &["commit", "-qm", "base"]);
|
||||
std::fs::write(project.join("a.txt"), "one\nDO NOT COMMIT\nthree\n").unwrap();
|
||||
|
||||
let window = Arc::new(Mutex::new(Window { revision: 7, ..Window::default() }));
|
||||
let host_slot: Arc<Mutex<Option<std::sync::Weak<PluginHost>>>> = Arc::default();
|
||||
let (w, slot) = (window.clone(), host_slot.clone());
|
||||
// Answers on another thread, as the UI does through `plugin_answer_commit`.
|
||||
let sink = Arc::new(move |_: Option<&str>, e: PluginEvent| {
|
||||
let PluginEvent::CommitRequested { request_id, request, .. } = e else { return };
|
||||
let (w, slot) = (w.clone(), slot.clone());
|
||||
std::thread::spawn(move || {
|
||||
let answer = {
|
||||
let mut w = w.lock();
|
||||
w.requests.push(request.clone());
|
||||
match request {
|
||||
CommitRequest::Selection => {
|
||||
let selection = w.selection.clone();
|
||||
if w.type_after_reading {
|
||||
w.revision += 1;
|
||||
}
|
||||
match selection {
|
||||
Some(s) => CommitAnswer::Selection { selection: s },
|
||||
None => CommitAnswer::NoRepository,
|
||||
}
|
||||
}
|
||||
CommitRequest::SetMessage { revision, message } if revision == w.revision => {
|
||||
w.message = message;
|
||||
w.revision += 1;
|
||||
CommitAnswer::Done
|
||||
}
|
||||
CommitRequest::SetMessage { .. } => CommitAnswer::Stale,
|
||||
}
|
||||
};
|
||||
let host = slot.lock().as_ref().and_then(std::sync::Weak::upgrade);
|
||||
assert!(host.unwrap().answer_commit(&request_id, answer));
|
||||
});
|
||||
});
|
||||
let host = Arc::new(PluginHost::new(tmp.path().join("plugins"), sink, Limits::default()).unwrap());
|
||||
*host_slot.lock() = Some(Arc::downgrade(&host));
|
||||
let workspace = Arc::new(Workspace { root: project.clone(), trusted: AtomicBool::new(true) });
|
||||
host.set_workspace(Some(workspace.clone()));
|
||||
host.install_from_dir(&fixture_dir(), &Consent::all()).unwrap();
|
||||
Self { host, window, workspace, project, _tmp: tmp }
|
||||
}
|
||||
|
||||
fn inv(&self) -> Invocation {
|
||||
Invocation { client: Some("main".into()), workspace_root: Some(self.project.clone()), ..Invocation::default() }
|
||||
}
|
||||
|
||||
/// `a.txt` included whole, with `message`.
|
||||
fn selection(&self, message: &str) -> CommitSelection {
|
||||
let revision = self.window.lock().revision;
|
||||
CommitSelection {
|
||||
revision,
|
||||
message: message.into(),
|
||||
amend: false,
|
||||
repositories: vec![CommitSelectionRepo {
|
||||
root: self.project.clone().into(),
|
||||
options: ide_vcs::CommitOptions {
|
||||
stage: vec![self.project.join("a.txt").into()],
|
||||
run_hooks: true,
|
||||
..Default::default()
|
||||
},
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn markers(&self, message: &str) -> Vec<PluginCommitCheckResult> {
|
||||
let check = PluginCommitCheckRef { plugin_id: "test.commit".into(), check: "markers".into() };
|
||||
let cancel = ide_core::CancellationToken::new();
|
||||
self.host.commit_checks(self.inv(), self.selection(message), vec![check], &cancel).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_reads_the_pending_commit_and_writes_its_message() {
|
||||
let f = Fixture::new();
|
||||
let selection = f.selection("");
|
||||
f.window.lock().selection = Some(selection);
|
||||
let out = f.host.run_command("test.commit", "describe", None, f.inv()).unwrap();
|
||||
assert_eq!(out.as_deref(), Some("chore: modified a.txt"));
|
||||
let w = f.window.lock();
|
||||
assert_eq!(w.message, "chore: modified a.txt");
|
||||
assert_eq!(w.revision, 8);
|
||||
// Reading the draft left the index alone: nothing is staged.
|
||||
let staged = Command::new("git").current_dir(&f.project).args(["diff", "--cached", "--name-only"]).output();
|
||||
assert!(staged.unwrap().stdout.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_meanwhile_makes_the_write_stale() {
|
||||
let f = Fixture::new();
|
||||
let selection = f.selection("");
|
||||
{
|
||||
let mut w = f.window.lock();
|
||||
w.selection = Some(selection);
|
||||
w.type_after_reading = true;
|
||||
}
|
||||
let err = f.host.run_command("test.commit", "describe", None, f.inv()).unwrap_err();
|
||||
assert!(err.message.contains("stale") || err.message.contains("changed"), "{}", err.message);
|
||||
assert_eq!(f.window.lock().message, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_repository_and_restricted_mode() {
|
||||
let f = Fixture::new();
|
||||
let err = f.host.run_command("test.commit", "describe", None, f.inv()).unwrap_err();
|
||||
assert!(err.message.contains("no repository"), "{}", err.message);
|
||||
|
||||
f.workspace.trusted.store(false, Ordering::Relaxed);
|
||||
f.window.lock().requests.clear();
|
||||
let err = f.host.run_command("test.commit", "describe", None, f.inv()).unwrap_err();
|
||||
assert!(err.message.contains("Restricted Mode"), "{}", err.message);
|
||||
assert!(f.window.lock().requests.is_empty(), "the window is not asked");
|
||||
|
||||
// Outside a window's command there is no Commit view to ask.
|
||||
let headless = Invocation { client: None, ..f.inv() };
|
||||
let err = f.host.run_command("test.commit", "describe", None, headless).unwrap_err();
|
||||
assert!(err.message.contains("from a command"), "{}", err.message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checks_report_the_message_and_markers_in_the_included_changes() {
|
||||
let f = Fixture::new();
|
||||
let results = f.markers("wip");
|
||||
assert_eq!(results.len(), 1);
|
||||
let r = &results[0];
|
||||
assert_eq!((r.title.as_str(), r.error.as_deref()), ("Commit Markers", None));
|
||||
let found: Vec<_> =
|
||||
r.problems.iter().map(|p| (p.message.as_str(), p.path.clone(), p.range.map(|r| r.start.line))).collect();
|
||||
assert_eq!(
|
||||
found,
|
||||
[
|
||||
("The message does not start with `type: `.", None, None),
|
||||
("Remove the DO NOT COMMIT marker.", Some(to_ipc(&f.project.join("a.txt"))), Some(1)),
|
||||
]
|
||||
);
|
||||
assert_eq!(f.markers("fix: drop marker")[0].problems.len(), 1);
|
||||
|
||||
// Unknown checks are not run, nor those of a plugin that lost `vcs.read`.
|
||||
let other = PluginCommitCheckRef { plugin_id: "test.commit".into(), check: "other".into() };
|
||||
let cancel = ide_core::CancellationToken::new();
|
||||
assert!(f.host.commit_checks(f.inv(), f.selection("x"), vec![other], &cancel).unwrap().is_empty());
|
||||
f.host.set_capability("test.commit", "vcs.read", false).unwrap();
|
||||
assert!(f.markers("wip").iter().all(|r| r.problems.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_commit_points_need_plugin_api_1_3() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host =
|
||||
PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
|
||||
// The manifest surgery below matches on `\n`; normalize so a CRLF checkout (Windows) behaves the same.
|
||||
let manifest = std::fs::read_to_string(fixture_dir().join("plugin.toml")).unwrap().replace("\r\n", "\n");
|
||||
for (what, manifest) in [
|
||||
(
|
||||
"commit.message",
|
||||
manifest.replace("[[contributes.commitChecks]]\nid = \"markers\"\ntitle = \"Commit Markers\"\n", ""),
|
||||
),
|
||||
(
|
||||
"commitChecks",
|
||||
manifest.replace("[[contributes.menus]]\nmenu = \"commit.message\"\ncommand = \"describe\"\n", ""),
|
||||
),
|
||||
] {
|
||||
let pkg = tmp.path().join(what);
|
||||
std::fs::create_dir_all(pkg.join("dist")).unwrap();
|
||||
std::fs::copy(fixture_dir().join("dist/plugin.wasm"), pkg.join("dist/plugin.wasm")).unwrap();
|
||||
std::fs::write(pkg.join("plugin.toml"), manifest.replace("\"^1.3\"", "\"^1.2\"")).unwrap();
|
||||
let err = host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
|
||||
assert!(err.message.contains("new in plugin API 1.3") && err.message.contains(what), "{what}: {}", err.message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use ide_core::{Pos, Range};
|
||||
use ide_host::plugins::{Consent, PluginHost};
|
||||
use ide_plugin::{Limits, ProviderDocument};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
fn fixture_dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/inlay-hints")
|
||||
}
|
||||
|
||||
fn host(dir: &Path) -> PluginHost {
|
||||
let limits = Limits { edit_soft: Duration::from_secs(5), edit_hard: Duration::from_secs(10), ..Limits::default() };
|
||||
PluginHost::new(dir.join("plugins"), Arc::new(|_: Option<&str>, _| {}), limits).unwrap()
|
||||
}
|
||||
|
||||
fn doc(dir: &Path, language: &str, text: &str) -> ProviderDocument {
|
||||
ProviderDocument { path: dir.join("Order.php"), language: language.into(), version: 1, text: text.into() }
|
||||
}
|
||||
|
||||
const ORDER: &str = "<?php\nclass Order\n{\n public function user(): ?User {}\n}\n$order->user();\n$order->total();\n$order->user;\n";
|
||||
|
||||
fn all() -> Range {
|
||||
Range::new(Pos::new(0, 0), Pos::new(100, 0))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calls_of_declared_methods_get_a_type_hint() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = host(tmp.path());
|
||||
host.install_from_dir(&fixture_dir(), &Consent::all()).unwrap();
|
||||
let hints = host.provide_inlay_hints(&doc(tmp.path(), "php", ORDER), all());
|
||||
let found: Vec<_> =
|
||||
hints.iter().map(|h| (h.position.line, h.position.col, h.label.as_str(), h.kind.as_str())).collect();
|
||||
assert_eq!(found, [(5, 14, ": ?User", "type")]);
|
||||
assert_eq!(hints[0].tooltip.as_deref(), Some("user() returns ?User"));
|
||||
assert!(!hints[0].padding_left && !hints[0].padding_right);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_declared_languages_are_asked() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = host(tmp.path());
|
||||
host.install_from_dir(&fixture_dir(), &Consent::all()).unwrap();
|
||||
assert!(host.provide_inlay_hints(&doc(tmp.path(), "javascript", ORDER), all()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_kind_needs_plugin_api_1_3() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = host(tmp.path());
|
||||
let pkg = tmp.path().join("old");
|
||||
std::fs::create_dir_all(pkg.join("dist")).unwrap();
|
||||
std::fs::copy(fixture_dir().join("dist/plugin.wasm"), pkg.join("dist/plugin.wasm")).unwrap();
|
||||
let manifest = std::fs::read_to_string(fixture_dir().join("plugin.toml")).unwrap();
|
||||
std::fs::write(pkg.join("plugin.toml"), manifest.replace("\"^1.3\"", "\"^1.0\"")).unwrap();
|
||||
let err = host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
|
||||
assert!(err.message.contains("new in plugin API 1.3"), "{}", err.message);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ type Events = Arc<Mutex<Vec<(Option<String>, PluginEvent)>>>;
|
||||
/// What plugins see of the test project: real `Documents`, as in the IDE.
|
||||
struct TestWorkspace {
|
||||
root: PathBuf,
|
||||
other_roots: Mutex<Vec<PathBuf>>,
|
||||
documents: Arc<ide_workspace::Documents>,
|
||||
trusted: bool,
|
||||
/// (producer, path) → published diagnostics.
|
||||
@@ -35,6 +36,7 @@ impl TestWorkspace {
|
||||
);
|
||||
Arc::new(Self {
|
||||
root: root.to_path_buf(),
|
||||
other_roots: Mutex::default(),
|
||||
documents: Arc::new(documents),
|
||||
trusted: true,
|
||||
diagnostics: Mutex::default(),
|
||||
@@ -67,7 +69,7 @@ impl TestWorkspace {
|
||||
|
||||
impl PluginWorkspace for TestWorkspace {
|
||||
fn projects(&self) -> Vec<PathBuf> {
|
||||
vec![self.root.clone()]
|
||||
std::iter::once(self.root.clone()).chain(self.other_roots.lock().iter().cloned()).collect()
|
||||
}
|
||||
fn buffer_text(&self, path: &Path) -> Option<String> {
|
||||
self.documents.text_of(path)
|
||||
@@ -361,6 +363,9 @@ fn read_file_is_confined_to_the_project() {
|
||||
let err = f.run("misbehave", "read", Some("src/../../secret.txt")).unwrap_err();
|
||||
assert!(err.contains("outside the project root"), "{err}");
|
||||
assert!(f.run("misbehave", "read", Some("/etc/hosts")).is_err());
|
||||
// A missing path inside the project is `not-found`, like the mock reports.
|
||||
let err = f.run("misbehave", "read", Some("src/missing.txt")).unwrap_err();
|
||||
assert!(err.contains("not found"), "{err}");
|
||||
// Without a project there is nothing to read.
|
||||
let inv = Invocation::default();
|
||||
let err = f.host.run_command("misbehave", "read", Some("src/notes.txt"), inv).unwrap_err().message;
|
||||
@@ -1528,8 +1533,8 @@ fn official_key_promoter() {
|
||||
assert_eq!(tips.len(), 1, "{tips:?}");
|
||||
assert_eq!(tips[0].0.as_deref(), Some("main"), "shown in the window the actions came from");
|
||||
assert!(tips[0].1.contains("Ctrl+D") && tips[0].2.contains("3 times"), "{tips:?}");
|
||||
let stored = std::fs::read_to_string(f.plugins_dir.join(".data/openidea.key-promoter/storage.json")).unwrap();
|
||||
assert!(stored.contains("\"count:code.duplicate\":\"3\""), "{stored}");
|
||||
let stored = stored(&f, "openidea.key-promoter");
|
||||
assert!(stored.contains(&("count:code.duplicate".into(), "3".into())), "{stored:?}");
|
||||
|
||||
// Uninstalling removes its data.
|
||||
f.host.uninstall("openidea.key-promoter").unwrap();
|
||||
@@ -1537,6 +1542,43 @@ fn official_key_promoter() {
|
||||
assert!(f.host.event_kinds().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_keeps_multimegabyte_indexes_after_idle_reclaim_and_removes_them_on_uninstall() {
|
||||
let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(100)), ..Limits::default() });
|
||||
let pkg = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/storage");
|
||||
f.install(&pkg);
|
||||
f.run("test.storage", "save", None).unwrap();
|
||||
assert_eq!(f.host.instances(), 1);
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
f.host.tick();
|
||||
assert_eq!(f.host.instances(), 0);
|
||||
assert_eq!(f.host.info_of("test.storage").unwrap().stats.reclaims, 1);
|
||||
assert_eq!(f.run("test.storage", "load", None).unwrap().as_deref(), Some("4194304"));
|
||||
assert_eq!(f.host.info_of("test.storage").unwrap().stats.activations, 2);
|
||||
let storage_dir = f.plugins_dir.join(".data/test.storage");
|
||||
assert_eq!(std::fs::read_dir(storage_dir.join("storage")).unwrap().count(), 1, "one file per key");
|
||||
f.host.uninstall("test.storage").unwrap();
|
||||
assert!(!storage_dir.exists());
|
||||
f.install(&pkg);
|
||||
assert!(f.run("test.storage", "load", None).unwrap_err().contains("missing index"));
|
||||
}
|
||||
|
||||
/// A plugin's stored entries (key, value), read from its files.
|
||||
fn stored(f: &Fixture, plugin: &str) -> Vec<(String, String)> {
|
||||
let dir = f.plugins_dir.join(".data").join(plugin).join("storage");
|
||||
let mut entries: Vec<_> = std::fs::read_dir(dir)
|
||||
.unwrap()
|
||||
.map(|e| {
|
||||
let bytes = std::fs::read(e.unwrap().path()).unwrap();
|
||||
let len = usize::from(u16::from_le_bytes([bytes[0], bytes[1]]));
|
||||
let key = String::from_utf8(bytes[2..2 + len].to_vec()).unwrap();
|
||||
(key, String::from_utf8(bytes[2 + len..].to_vec()).unwrap())
|
||||
})
|
||||
.collect();
|
||||
entries.sort();
|
||||
entries
|
||||
}
|
||||
|
||||
fn git(dir: &Path, args: &[&str]) {
|
||||
let out = std::process::Command::new("git")
|
||||
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "init.defaultBranch=main"])
|
||||
@@ -1601,8 +1643,8 @@ fn official_git_toolbox_fetches_on_schedule_after_idle_reclaim() {
|
||||
)
|
||||
);
|
||||
assert!(events.iter().any(|(_, e)| matches!(e, PluginEvent::TaskEnded { .. })));
|
||||
let stored = std::fs::read_to_string(f.plugins_dir.join(".data/openidea.git-toolbox/storage.json")).unwrap();
|
||||
assert!(stored.contains("last-fetch:"), "{stored}");
|
||||
let stored = stored(&f, "openidea.git-toolbox");
|
||||
assert!(stored.iter().any(|(key, _)| key.starts_with("last-fetch:")), "{stored:?}");
|
||||
}
|
||||
|
||||
/// Restricted Mode: git does not run in an untrusted project (no fetch).
|
||||
@@ -1613,6 +1655,7 @@ fn git_toolbox_does_not_fetch_untrusted_projects() {
|
||||
f.install(&official("git-toolbox"));
|
||||
let untrusted = Arc::new(TestWorkspace {
|
||||
root: f.project.clone(),
|
||||
other_roots: Mutex::default(),
|
||||
documents: f.workspace.documents.clone(),
|
||||
trusted: false,
|
||||
diagnostics: Mutex::default(),
|
||||
@@ -1940,6 +1983,35 @@ fn official_csv_editor_edits_the_shared_document() {
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::NotFound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_menu_entries_can_name_a_submenu() {
|
||||
fn with_menu(version: &'static str, extra: &'static str) -> impl Fn(&str) -> String {
|
||||
move |m: &str| {
|
||||
let m = m.replace("engines.openidea = \"^1.0\"", &format!("engines.openidea = \"{version}\""));
|
||||
format!("{m}\n[[contributes.menus]]\ncommand = \"word-count\"\n{extra}\n")
|
||||
}
|
||||
}
|
||||
let f = fixture(Limits::default());
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ok = hello_variant(dir.path(), with_menu("^1.3", "menu = \"editor.context\"\nsubmenu = \"Words\""));
|
||||
assert_eq!(f.install(&ok).status, PluginStatus::Inactive);
|
||||
let submenus: Vec<_> =
|
||||
f.host.ui_items("menus").into_iter().filter_map(|i| i.decl["submenu"].as_str().map(str::to_owned)).collect();
|
||||
assert_eq!(submenus, ["Words"]);
|
||||
f.host.uninstall("hello").unwrap();
|
||||
|
||||
for (version, extra, message) in [
|
||||
("^1.3", "menu = \"tools\"\nsubmenu = \"Words\"", "only for context menus"),
|
||||
("^1.3", "menu = \"editor.context\"\nsubmenu = \" \"", "titles are"),
|
||||
("^1.2", "menu = \"editor.context\"\nsubmenu = \"Words\"", "1.3"),
|
||||
] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let pkg = hello_variant(dir.path(), with_menu(version, extra));
|
||||
let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
|
||||
assert!(err.message.contains(message), "{extra}: {}", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_item_menus_name_a_declared_view() {
|
||||
/// The menu is new in API 1.2.
|
||||
@@ -2045,6 +2117,45 @@ fn sample_plugin_tool_window_with_a_tree() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The tables fixture sorts its own rows: a `table` body sent through `other`
|
||||
/// opts the headers in, and a header click leaves as an `other` view-event
|
||||
/// (kind `sort`) the plugin decodes and answers with the rows in the asked
|
||||
/// order (plugins/README.md §10: released types stay, `other` carries new
|
||||
/// cases).
|
||||
#[test]
|
||||
fn sortable_plugin_table_round_trips_sort_events() {
|
||||
let f = fixture(Limits::default());
|
||||
let info = f.install(&sample("tables"));
|
||||
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
|
||||
|
||||
let table = |f: &Fixture| match f.host.render_view("tables", "tables", None, f.inv()).unwrap().body {
|
||||
ide_plugin::ViewBody::Table(t) => t,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
// No sort yet: headers are clickable, rows in the plugin's own order.
|
||||
let t = table(&f);
|
||||
assert_eq!((t.columns.clone(), t.total_rows), (vec!["ext".to_owned(), "files".into()], 4));
|
||||
assert_eq!((t.sortable, t.sort), (true, None), "a `table` through `other` opts the headers in");
|
||||
assert_eq!(t.rows.iter().map(|r| r[0].clone()).collect::<Vec<_>>(), ["rs", "md", "java", "toml"]);
|
||||
|
||||
f.take_events();
|
||||
let sort = |f: &Fixture, descending| {
|
||||
f.host.view_event("tables", "tables", ide_plugin::ViewEvent::Sort { column: 1, descending }, f.inv()).unwrap();
|
||||
assert!(f.take_events().into_iter().any(
|
||||
|(_, e)| matches!(e, PluginEvent::ViewChanged { ref plugin_id, ref view } if plugin_id == "tables" && view == "tables")
|
||||
));
|
||||
table(f)
|
||||
};
|
||||
|
||||
// The plugin reports the sort it ordered by, with the rows to match.
|
||||
let t = sort(&f, false);
|
||||
assert_eq!(t.sort, Some(ide_plugin::TableSort { column: 1, descending: false }));
|
||||
assert_eq!(t.rows.iter().map(|r| r[1].clone()).collect::<Vec<_>>(), ["3", "9", "12", "20"]);
|
||||
let t = sort(&f, true);
|
||||
assert_eq!(t.sort, Some(ide_plugin::TableSort { column: 1, descending: true }));
|
||||
assert_eq!(t.rows.iter().map(|r| r[1].clone()).collect::<Vec<_>>(), ["20", "12", "9", "3"]);
|
||||
}
|
||||
|
||||
/// Regex Tester: an `html` body its script talks to; the plugin answers in
|
||||
/// view messages, in UTF-16 offsets.
|
||||
#[test]
|
||||
@@ -2270,9 +2381,14 @@ fn commands_ask_the_user_and_reveal_their_views() {
|
||||
|
||||
let (result, prompt) = run_and_answer(&f, "input", Some("Main"), Some(&["Other"]), none);
|
||||
assert_eq!(result.unwrap().as_deref(), Some("Other"));
|
||||
assert_eq!(prompt, Prompt::Input { prompt: "Name".into(), value: Some("Main".into()) });
|
||||
assert_eq!(prompt, Prompt::Input { prompt: "Name".into(), value: Some("Main".into()), multiline: false });
|
||||
assert_eq!(run_and_answer(&f, "input", None, None, none).0.unwrap().as_deref(), Some("none"));
|
||||
|
||||
let (result, prompt) = run_and_answer(&f, "input-multiline", Some("one\ntwo"), Some(&["a\nb"]), none);
|
||||
assert_eq!(result.unwrap().as_deref(), Some("a\nb"));
|
||||
assert_eq!(prompt, Prompt::Input { prompt: "Name".into(), value: Some("one\ntwo".into()), multiline: true });
|
||||
assert_eq!(run_and_answer(&f, "input-multiline", None, None, none).0.unwrap().as_deref(), Some("none"));
|
||||
|
||||
let (result, prompt) = run_and_answer(&f, "confirm", None, Some(&[]), none);
|
||||
assert_eq!(result.unwrap().as_deref(), Some("true"));
|
||||
assert_eq!(prompt, Prompt::Confirm { title: "Delete".into(), message: "Really?".into() });
|
||||
@@ -2291,7 +2407,7 @@ fn commands_ask_the_user_and_reveal_their_views() {
|
||||
// Without a window there is nothing to attach the dialog to.
|
||||
f.take_events();
|
||||
let no_window = Invocation { client: None, ..f.inv() };
|
||||
for cmd in ["pick", "input", "confirm"] {
|
||||
for cmd in ["pick", "input", "input-multiline", "confirm"] {
|
||||
let err = f.host.run_command("jobs", cmd, None, no_window.clone()).unwrap_err().message;
|
||||
assert!(err.contains("from a command the user invoked"), "{cmd}: {err}");
|
||||
}
|
||||
@@ -2379,6 +2495,46 @@ web = "panel/index.html"
|
||||
assert!(assets[0].1.contains("<rect"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_package_ships_translations_in_locales() {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let package = |id: &str, catalogs: &[(&str, &str)]| {
|
||||
let pkg = tmp.path().join(id);
|
||||
std::fs::create_dir_all(pkg.join("locales")).unwrap();
|
||||
for (name, text) in catalogs {
|
||||
std::fs::write(pkg.join("locales").join(name), text).unwrap();
|
||||
}
|
||||
std::fs::write(
|
||||
pkg.join("plugin.toml"),
|
||||
format!(
|
||||
"id = \"{id}\"\nname = \"Words\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\n\
|
||||
[[contributes.commands]]\nid = \"count\"\ntitle = \"Count Words\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
pkg
|
||||
};
|
||||
|
||||
for bad in ["{", "[]", r#"{"Count Words": 1}"#] {
|
||||
let pkg = package("bad-catalog", &[("fr.json", bad)]);
|
||||
assert!(f.host.install_from_dir(&pkg, &Consent::all()).is_err(), "{bad}");
|
||||
}
|
||||
|
||||
let pkg = package("words", &[("fr.json", r#"{"Count Words": "Compter les mots"}"#), ("zh-CN.json", "{}")]);
|
||||
let info = f.install(&pkg);
|
||||
assert!(info.error.is_none(), "{:?}", info.error);
|
||||
let item = f.host.ui_items("locales").into_iter().find(|i| i.plugin_id == "words").expect("the catalogs");
|
||||
assert_eq!(item.decl["tags"], serde_json::json!(["fr", "zh-CN"]));
|
||||
let assets = f.host.assets("words", "locale/fr").unwrap();
|
||||
assert_eq!(assets.len(), 1);
|
||||
assert!(assets[0].1.contains("Compter les mots"));
|
||||
|
||||
let other = f.install(&package("other", &[]));
|
||||
assert!(other.error.is_none(), "{:?}", other.error);
|
||||
assert!(f.host.assets("other", "locale/").unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// A plugin with web views and no code: its pages and their files are
|
||||
/// installed with it and served only from the declared directories, while
|
||||
/// it is enabled and only for the installed version; file icons and
|
||||
@@ -2798,6 +2954,30 @@ fn completion_quick_fixes_problems_and_event_kinds_of_api_1() {
|
||||
let foreign = ide_plugin::CodeAction { data: Some("other".into()), ..action };
|
||||
assert!(f.host.resolve_code_action(&plugin, &doc("lazy"), foreign).is_err());
|
||||
|
||||
// File operations come from the `file-actions` export, next to the providers' own.
|
||||
let mut actions = f.host.provide_code_actions(&doc("files"), ide_core::Range::default()).into_iter();
|
||||
let (_, action) = actions.next().expect("offered");
|
||||
let (plugin, later) = actions.next().expect("a lazy one too");
|
||||
let dir = f.project.clone();
|
||||
assert_eq!(action.title, "Reorganise");
|
||||
assert_eq!(
|
||||
action.operations,
|
||||
vec![
|
||||
ide_plugin::FileOperation::Create { path: dir.join("created.txt"), text: "hello".into(), overwrite: false },
|
||||
ide_plugin::FileOperation::Rename { from: dir.join("old.txt"), to: dir.join("renamed.txt") },
|
||||
ide_plugin::FileOperation::Delete { path: dir.join("gone.txt") },
|
||||
]
|
||||
);
|
||||
assert_eq!(action.open, Some(dir.join("created.txt")));
|
||||
// Listed with `data` only: `resolve` on the same export computes its operations.
|
||||
assert!(later.file_action && later.operations.is_empty() && later.data.is_some());
|
||||
let resolved = f.host.resolve_code_action(&plugin, &doc("files"), later).unwrap();
|
||||
assert_eq!(
|
||||
resolved.operations,
|
||||
vec![ide_plugin::FileOperation::Create { path: dir.join("later.txt"), text: "later".into(), overwrite: false }]
|
||||
);
|
||||
assert!(f.host.provide_code_actions(&doc("rich"), ide_core::Range::default()).is_empty());
|
||||
|
||||
// A kind this IDE doesn't know is refused, not a trap.
|
||||
let err = f.host.run_command("misbehave", "subscribe", Some("file-deleted"), f.inv()).unwrap_err();
|
||||
assert!(err.message.contains("no event kind"), "{}", err.message);
|
||||
@@ -3175,3 +3355,334 @@ fn invalid_plugin_icons_refuse_the_package() {
|
||||
assert!(err.contains(expected), "{id}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_exports_refresh_matching_files_and_follow_plugin_lifecycle() {
|
||||
let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(50)), ..Limits::default() });
|
||||
let routes = f.project.join("routes.txt");
|
||||
std::fs::write(&routes, "/users/{id}\n").unwrap();
|
||||
f.install(&sample("endpoints"));
|
||||
let id = "test.endpoints";
|
||||
let paths = || f.host.endpoints(id, &f.project).unwrap().iter().map(|e| e.path.clone()).collect::<Vec<_>>();
|
||||
let first = f.host.endpoints(id, &f.project).unwrap();
|
||||
assert_eq!(first[0].path, "/users/{id}");
|
||||
assert!(first[0].method.is_none(), "any method is preserved");
|
||||
assert_eq!(first[0].location.path, ide_core::IpcPath::from(to_ipc(&routes)));
|
||||
std::thread::sleep(Duration::from_millis(70));
|
||||
f.host.tick();
|
||||
assert_eq!(f.host.instances(), 0);
|
||||
assert_eq!(paths(), ["/users/{id}"]);
|
||||
assert_eq!(f.host.instances(), 0, "a cached list doesn't wake the plugin");
|
||||
let changed = |f: &Fixture| {
|
||||
f.take_events()
|
||||
.iter()
|
||||
.any(|(_, e)| matches!(e, PluginEvent::EndpointsChanged { plugin_id, .. } if plugin_id == id))
|
||||
};
|
||||
std::fs::write(&routes, "/updated\n").unwrap();
|
||||
f.take_events();
|
||||
f.host.invalidate_endpoints(&f.project, &[f.project.join("unrelated.txt")]);
|
||||
assert!(!changed(&f));
|
||||
assert_eq!(paths(), ["/users/{id}"]);
|
||||
f.host.invalidate_endpoints(&f.project, std::slice::from_ref(&routes));
|
||||
assert!(changed(&f), "windows are told to ask again");
|
||||
assert_eq!(paths(), ["/updated"]);
|
||||
std::fs::write(&routes, "not-a-path\n").unwrap();
|
||||
f.host.invalidate_endpoints(&f.project, std::slice::from_ref(&routes));
|
||||
assert_eq!(paths(), ["/updated"], "an invalid list doesn't replace the last valid one");
|
||||
f.host.set_enabled(id, false).unwrap();
|
||||
assert!(paths().is_empty());
|
||||
std::fs::write(&routes, "/enabled\n").unwrap();
|
||||
f.host.set_enabled(id, true).unwrap();
|
||||
assert_eq!(paths(), ["/enabled"]);
|
||||
f.take_events();
|
||||
assert!(f.run(id, "crash", None).is_err());
|
||||
assert!(changed(&f), "a crash withdraws the routes");
|
||||
f.host.uninstall(id).unwrap();
|
||||
assert!(f.host.endpoints(id, &f.project).is_err());
|
||||
}
|
||||
|
||||
struct EndpointWorkspace {
|
||||
reads: std::sync::atomic::AtomicUsize,
|
||||
inner: Arc<TestWorkspace>,
|
||||
roots: Mutex<Vec<PathBuf>>,
|
||||
entered: Mutex<Option<std::sync::mpsc::Sender<()>>>,
|
||||
release: Mutex<std::sync::mpsc::Receiver<()>>,
|
||||
}
|
||||
impl PluginWorkspace for EndpointWorkspace {
|
||||
fn projects(&self) -> Vec<PathBuf> {
|
||||
self.roots.lock().clone()
|
||||
}
|
||||
fn buffer_text(&self, path: &Path) -> Option<String> {
|
||||
if self.reads.fetch_add(1, std::sync::atomic::Ordering::Relaxed) > 0
|
||||
&& let Some(entered) = self.entered.lock().take()
|
||||
{
|
||||
entered.send(()).unwrap();
|
||||
self.release.lock().recv().unwrap();
|
||||
}
|
||||
self.inner.buffer_text(path)
|
||||
}
|
||||
fn document(&self, path: &Path) -> Option<(u64, String)> {
|
||||
self.inner.document(path)
|
||||
}
|
||||
fn trusted(&self, root: &Path) -> bool {
|
||||
self.roots.lock().iter().any(|r| r == root)
|
||||
}
|
||||
fn project_of(&self, _: &str) -> Option<PathBuf> {
|
||||
self.roots.lock().first().cloned()
|
||||
}
|
||||
fn apply_edits(&self, path: &Path, version: u64, edits: &[ide_core::TextEdit]) -> Result<u64, HostError> {
|
||||
self.inner.apply_edits(path, version, edits)
|
||||
}
|
||||
fn publish_diagnostics(
|
||||
&self,
|
||||
source: &str,
|
||||
path: &Path,
|
||||
items: Vec<ide_core::Diagnostic>,
|
||||
) -> Result<(), HostError> {
|
||||
self.inner.publish_diagnostics(source, path, items)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_endpoint_results_cannot_restore_a_disabled_plugin_or_closed_project() {
|
||||
for close_project in [false, true] {
|
||||
let f = fixture(Limits::default());
|
||||
std::fs::write(f.project.join("routes.txt"), "/late\n").unwrap();
|
||||
f.install(&sample("endpoints"));
|
||||
let (entered_tx, entered_rx) = std::sync::mpsc::channel();
|
||||
let (release_tx, release_rx) = std::sync::mpsc::channel();
|
||||
let workspace = Arc::new(EndpointWorkspace {
|
||||
reads: std::sync::atomic::AtomicUsize::new(0),
|
||||
inner: f.workspace.clone(),
|
||||
roots: Mutex::new(vec![f.project.clone()]),
|
||||
entered: Mutex::new(Some(entered_tx)),
|
||||
release: Mutex::new(release_rx),
|
||||
});
|
||||
f.host.set_workspace(Some(workspace.clone()));
|
||||
let late = std::thread::scope(|scope| {
|
||||
// The plugin reads the file, then the host's check reads it again and waits here.
|
||||
let query = scope.spawn(|| f.host.endpoints("test.endpoints", &f.project).unwrap());
|
||||
entered_rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
if close_project {
|
||||
workspace.roots.lock().clear();
|
||||
} else {
|
||||
f.host.set_enabled("test.endpoints", false).unwrap();
|
||||
}
|
||||
release_tx.send(()).unwrap();
|
||||
query.join().unwrap()
|
||||
});
|
||||
assert!(late.is_empty());
|
||||
assert!(f.host.endpoints("test.endpoints", &f.project).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_results_are_isolated_per_root_and_withdraw_closed_roots() {
|
||||
let f = fixture(Limits::default());
|
||||
let other = f.project.parent().unwrap().join("other");
|
||||
std::fs::create_dir(&other).unwrap();
|
||||
std::fs::write(f.project.join("routes.txt"), "/first\n").unwrap();
|
||||
std::fs::write(other.join("routes.txt"), "/second\n").unwrap();
|
||||
let (_, release_rx) = std::sync::mpsc::channel();
|
||||
let workspace = Arc::new(EndpointWorkspace {
|
||||
reads: std::sync::atomic::AtomicUsize::new(0),
|
||||
inner: f.workspace.clone(),
|
||||
roots: Mutex::new(vec![f.project.clone(), other.clone()]),
|
||||
entered: Mutex::new(None),
|
||||
release: Mutex::new(release_rx),
|
||||
});
|
||||
f.host.set_workspace(Some(workspace.clone()));
|
||||
f.install(&sample("endpoints"));
|
||||
for (root, route) in [(&f.project, "/first"), (&other, "/second")] {
|
||||
let list = f.host.endpoints("test.endpoints", root).unwrap();
|
||||
assert_eq!(list.iter().map(|e| e.path.as_str()).collect::<Vec<_>>(), [route]);
|
||||
}
|
||||
workspace.roots.lock().retain(|r| r != &other);
|
||||
assert!(f.host.endpoints("test.endpoints", &other).unwrap().is_empty(), "a closed project has no routes");
|
||||
assert_eq!(f.host.endpoints("test.endpoints", &f.project).unwrap()[0].path, "/first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_actions_keep_commands_and_window_scope() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&sample("jobs"));
|
||||
f.take_events();
|
||||
f.host
|
||||
.run_command("jobs", "notify-actions", None, Invocation { client: Some("second".into()), ..f.inv() })
|
||||
.unwrap();
|
||||
let notifications = f
|
||||
.take_events()
|
||||
.into_iter()
|
||||
.filter_map(|(client, event)| match event {
|
||||
PluginEvent::Notification { plugin_id, actions, .. } => Some((client, plugin_id, actions)),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(notifications.len(), 1);
|
||||
assert_eq!(notifications[0].0.as_deref(), Some("second"));
|
||||
assert_eq!(notifications[0].1, "jobs");
|
||||
assert_eq!(notifications[0].2.len(), 3);
|
||||
assert_eq!(notifications[0].2[0].command, "context");
|
||||
assert_eq!(notifications[0].2[0].argument.as_deref(), Some("notification-action=1\nvalue"));
|
||||
for invalid in ["four", "unknown"] {
|
||||
let error = f.run("jobs", "notify-actions", Some(invalid)).unwrap_err();
|
||||
assert!(error.contains(if invalid == "four" { "at most 3" } else { "declares no command" }), "{error}");
|
||||
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::Notification { .. })));
|
||||
}
|
||||
assert_eq!(f.status("jobs"), PluginStatus::Active, "a refused notification leaves the plugin running");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_prompts_and_project_requests_use_the_calling_window_without_a_capability() {
|
||||
use ide_plugin::Prompt;
|
||||
let f = fixture(Limits { timeout: Duration::from_millis(500), ..Limits::default() });
|
||||
f.install(&sample("jobs"));
|
||||
f.host.set_capability("jobs", "project.read", false).unwrap();
|
||||
let (result, prompt) =
|
||||
run_and_answer(&f, "pick-directory", Some("C:/work"), Some(&["C:/work/repo"]), Duration::from_millis(700));
|
||||
assert_eq!(result.unwrap().as_deref(), Some("C:/work/repo"));
|
||||
assert_eq!(prompt, Prompt::PickDirectory { title: "Clone target".into(), initial: Some("C:/work".into()) });
|
||||
assert_eq!(run_and_answer(&f, "pick-directory", None, None, Duration::ZERO).0.unwrap().as_deref(), Some("none"));
|
||||
assert!(run_and_answer(&f, "pick-directory", None, Some(&["relative"]), Duration::ZERO).0.is_err());
|
||||
assert!(f.run("jobs", "pick-directory", Some("relative")).is_err());
|
||||
for (cmd, new_window) in [("open-project", false), ("open-project-new", true)] {
|
||||
f.take_events();
|
||||
let inv = Invocation { client: Some("second".into()), ..f.inv() };
|
||||
f.host.run_command("jobs", cmd, Some("C:/work/repo"), inv).unwrap();
|
||||
let requests = f
|
||||
.take_events()
|
||||
.into_iter()
|
||||
.filter_map(|(client, event)| match event {
|
||||
PluginEvent::OpenProject { plugin_id, path, new_window } => Some((client, plugin_id, path, new_window)),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(requests, vec![(Some("second".into()), "jobs".into(), "C:/work/repo".into(), new_window)]);
|
||||
let error = f.host.run_command("jobs", cmd, Some("/work/repo"), Invocation::default()).unwrap_err();
|
||||
assert_eq!(error.kind, ErrorKind::InvalidInput, "{error:?}");
|
||||
}
|
||||
assert!(f.host.run_command("jobs", "pick-directory", None, Invocation::default()).is_err());
|
||||
for path in ["relative", "/bad\npath", ""] {
|
||||
f.take_events();
|
||||
assert!(f.run("jobs", "open-project", Some(path)).is_err());
|
||||
assert!(!f.take_events().iter().any(|(_, event)| matches!(event, PluginEvent::OpenProject { .. })));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_readonly_fields_and_table_row_extensions_round_trip() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&sample("tables"));
|
||||
let view = f.host.render_view("tables", "fields", None, f.inv()).unwrap();
|
||||
let ide_plugin::ViewBody::Form(fields) = view.body else { panic!("form") };
|
||||
assert_eq!(fields[0].value, ide_plugin::FieldValue::Label("Merge request #42".into()));
|
||||
assert_eq!(fields[1].value, ide_plugin::FieldValue::ReadonlyText { text: "Ada Lovelace".into(), multiline: false });
|
||||
assert_eq!(
|
||||
fields[2].value,
|
||||
ide_plugin::FieldValue::ReadonlyText { text: "Ready for review\nAll checks passed".into(), multiline: true }
|
||||
);
|
||||
assert!(matches!(fields[3].value, ide_plugin::FieldValue::Text(_)));
|
||||
for (event, status) in [
|
||||
(ide_plugin::ViewEvent::TableSelect { row: 2 }, "select:2"),
|
||||
(ide_plugin::ViewEvent::TableActivate { row: 2 }, "activate:2"),
|
||||
] {
|
||||
f.take_events();
|
||||
f.host.view_event("tables", "tables", event, f.inv()).unwrap();
|
||||
assert!(f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::ViewChanged { plugin_id, view } if plugin_id == "tables" && view == "tables")));
|
||||
assert_eq!(f.host.render_view("tables", "tables", None, f.inv()).unwrap().status.as_deref(), Some(status));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_filtered_events_do_not_wake_idle_plugins_and_recheck_queued_filters() {
|
||||
let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(100)), ..Limits::default() });
|
||||
f.install(&sample("path-events"));
|
||||
f.run("test.path-events", "count", None).unwrap();
|
||||
let matched = to_ipc(&f.project.join("config/app.yaml"));
|
||||
let other = to_ipc(&f.project.join("src/main.rs"));
|
||||
let deliver = |events| {
|
||||
f.host.post_events(Some("main"), events);
|
||||
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
|
||||
assert!(f.host.wait_events(Duration::from_secs(10)));
|
||||
};
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
f.host.tick();
|
||||
assert_eq!(f.status("test.path-events"), PluginStatus::Inactive);
|
||||
let activations = f.host.info_of("test.path-events").unwrap().stats.activations;
|
||||
deliver(vec![WorkbenchEvent::FileOpened(other.clone()), WorkbenchEvent::FileSaved(other.clone())]);
|
||||
assert_eq!(f.host.info_of("test.path-events").unwrap().stats.activations, activations);
|
||||
deliver(vec![WorkbenchEvent::FileOpened(matched.clone()), WorkbenchEvent::FileSaved(matched.clone())]);
|
||||
assert_eq!(f.run("test.path-events", "count", None).unwrap().as_deref(), Some("2"));
|
||||
assert!(f.run("test.path-events", "filter", Some("../*")).is_err());
|
||||
deliver(vec![WorkbenchEvent::FileOpened(matched.clone())]);
|
||||
assert_eq!(
|
||||
f.run("test.path-events", "count", None).unwrap().as_deref(),
|
||||
Some("3"),
|
||||
"invalid filter preserves previous one"
|
||||
);
|
||||
|
||||
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(matched.clone())]);
|
||||
f.run("test.path-events", "filter", None).unwrap();
|
||||
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
|
||||
assert!(f.host.wait_events(Duration::from_secs(10)));
|
||||
assert_eq!(
|
||||
f.run("test.path-events", "count", None).unwrap().as_deref(),
|
||||
Some("3"),
|
||||
"queued event uses current filter"
|
||||
);
|
||||
deliver(vec![WorkbenchEvent::FileOpened(matched.clone())]);
|
||||
assert_eq!(f.run("test.path-events", "count", None).unwrap().as_deref(), Some("3"));
|
||||
f.run("test.path-events", "all", None).unwrap();
|
||||
deliver(vec![WorkbenchEvent::FileOpened(other.clone())]);
|
||||
assert_eq!(f.run("test.path-events", "count", None).unwrap().as_deref(), Some("4"));
|
||||
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(other.clone())]);
|
||||
f.run("test.path-events", "off", None).unwrap();
|
||||
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
|
||||
assert!(f.host.wait_events(Duration::from_secs(10)));
|
||||
deliver(vec![WorkbenchEvent::FileOpened(other)]);
|
||||
assert_eq!(f.run("test.path-events", "count", None).unwrap().as_deref(), Some("4"));
|
||||
|
||||
f.host.set_enabled("test.path-events", false).unwrap();
|
||||
assert!(!f.host.event_kinds().contains(&WorkbenchEvent::FileOpened(String::new()).kind()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_lookup_works_in_commands_and_provider_calls_without_an_invocation_project() {
|
||||
let f = fixture(Limits::default());
|
||||
f.install(&sample("path-events"));
|
||||
let path = f.project.join("src/notes.txt");
|
||||
assert_eq!(f.run("test.path-events", "project", Some(&to_ipc(&path))).unwrap(), Some(to_ipc(&f.project)));
|
||||
assert_eq!(f.run("test.path-events", "project", Some("src/notes.txt")).unwrap(), None);
|
||||
assert_eq!(
|
||||
f.run("test.path-events", "project", Some(&to_ipc(&f.project.with_extension("other").join("a")))).unwrap(),
|
||||
None
|
||||
);
|
||||
let doc = ide_plugin::ProviderDocument { path, language: "text".into(), version: 1, text: "one".into() };
|
||||
let hovers = f.host.provide_hover(&doc, ide_core::Pos::new(0, 0));
|
||||
assert_eq!(hovers.len(), 1);
|
||||
assert_eq!(hovers[0].markdown, to_ipc(&f.project));
|
||||
let nested = f.project.join("nested");
|
||||
std::fs::create_dir(&nested).unwrap();
|
||||
f.workspace.other_roots.lock().push(nested.clone());
|
||||
assert_eq!(
|
||||
f.run("test.path-events", "project", Some(&to_ipc(&nested.join("new.txt")))).unwrap(),
|
||||
Some(to_ipc(&nested))
|
||||
);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(&nested, f.project.join("alias")).unwrap();
|
||||
assert_eq!(
|
||||
f.run("test.path-events", "project", Some(&to_ipc(&f.project.join("alias/new.txt")))).unwrap(),
|
||||
Some(to_ipc(&nested))
|
||||
);
|
||||
std::os::unix::fs::symlink(f._tmp.path(), f.project.join("outside")).unwrap();
|
||||
assert_eq!(
|
||||
f.run("test.path-events", "project", Some(&to_ipc(&f.project.join("outside/new.txt")))).unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
f.host.set_capability("test.path-events", "project.read", false).unwrap();
|
||||
assert!(f.run("test.path-events", "project", Some(&to_ipc(&doc.path))).unwrap_err().contains("project.read"));
|
||||
f.host.set_capability("test.path-events", "events", false).unwrap();
|
||||
assert!(f.run("test.path-events", "filter", Some("*")).unwrap_err().contains("events"));
|
||||
}
|
||||
@@ -164,6 +164,9 @@ async fn remote_projects_run_the_desktops_code_plugins_on_the_server() {
|
||||
let kinds = invoke(&peer, "plugin_event_kinds", json!({})).await.unwrap();
|
||||
assert!(kinds.as_array().unwrap().contains(&json!("file-saved")), "{kinds}");
|
||||
|
||||
invoke(&peer, "plugin_set_ui_locale", json!({ "tag": "fr" })).await.unwrap();
|
||||
assert_eq!(plugins.host().ui_locale(), "fr");
|
||||
|
||||
// Saving a file that exists only on the server: Checkstyle reads that file.
|
||||
let java = project.join("src/A.java");
|
||||
std::fs::write(&java, "class A {\n\tint x;\n}\n").unwrap();
|
||||
|
||||
@@ -33,6 +33,8 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
|
||||
for command in [
|
||||
"app_info",
|
||||
"app_build_info",
|
||||
"app_cli_install",
|
||||
"app_cli_uninstall",
|
||||
"app_log_location",
|
||||
"app_check_update",
|
||||
"app_startup_update_check",
|
||||
|
||||
@@ -346,7 +346,7 @@ impl Drop for KillOnDrop {
|
||||
/// child's `PATH` (a caller's `env` PATH, else the login shell's, else ours), so
|
||||
/// that on Windows `PATHEXT` names (`codex` → `codex.cmd`) start; anything else
|
||||
/// is kept as given.
|
||||
fn resolve_spawn_program(command: &str, env: &[(String, String)]) -> std::path::PathBuf {
|
||||
pub fn resolve_spawn_program(command: &str, env: &[(String, String)]) -> std::path::PathBuf {
|
||||
let path = std::path::Path::new(command);
|
||||
if path.components().count() > 1 || path.is_absolute() {
|
||||
return path.to_path_buf();
|
||||
|
||||
+228
-5
@@ -41,6 +41,12 @@ const ACCESS_OPTIONS: [&str; 2] = [
|
||||
"org.eclipse.jdt.core.compiler.problem.discouragedReference",
|
||||
];
|
||||
|
||||
const LEVEL_OPTIONS: [&str; 3] = [
|
||||
"org.eclipse.jdt.core.compiler.source",
|
||||
"org.eclipse.jdt.core.compiler.compliance",
|
||||
"org.eclipse.jdt.core.compiler.codegen.targetPlatform",
|
||||
];
|
||||
|
||||
/// Keep the source model in protocol settings so jdtls's invisible-project
|
||||
/// initialization and configuration use the same roots as workspace builds.
|
||||
pub fn configure_source_paths(cfg: &mut ide_language::LanguageServerConfig, paths: Option<Vec<String>>) {
|
||||
@@ -54,6 +60,115 @@ pub fn configure_source_paths(cfg: &mut ide_language::LanguageServerConfig, path
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies Project Structure compiler output path, project libraries (and source attachments),
|
||||
/// and Web facet `WEB-INF/lib` directories to jdtls `java.project.*` settings.
|
||||
pub fn configure_project_structure(
|
||||
cfg: &mut ide_language::LanguageServerConfig,
|
||||
root: &Path,
|
||||
server_root: &Path,
|
||||
structure: &ide_core::settings::ProjectStructureSettings,
|
||||
) {
|
||||
if cfg.name != SERVER {
|
||||
return;
|
||||
}
|
||||
let module_cfg = structure.module_settings(root, server_root);
|
||||
let module_output = module_cfg.map(|m| m.compiler_output.trim()).filter(|s| !s.is_empty());
|
||||
let output_path = if let Some(output) = module_output {
|
||||
// The dialog stores paths relative to the project, JDT expects the server root.
|
||||
let path = root.join(output);
|
||||
match path.strip_prefix(server_root) {
|
||||
Ok(relative) => Some(relative.to_string_lossy().replace('\\', "/")),
|
||||
Err(_) => {
|
||||
cfg.launch_error = Some(format!("Java compiler output must be inside its module: {}", path.display()));
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let output = structure.compiler_output.trim();
|
||||
if output.is_empty() {
|
||||
None
|
||||
} else if Path::new(output).is_absolute() {
|
||||
match Path::new(output).strip_prefix(server_root) {
|
||||
Ok(relative) => Some(relative.to_string_lossy().replace('\\', "/")),
|
||||
Err(_) => {
|
||||
cfg.launch_error = Some(format!("Java compiler output must be inside its module: {output}"));
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Some(output.replace('\\', "/"))
|
||||
}
|
||||
};
|
||||
|
||||
let normalize = |entry: &str| -> Option<String> {
|
||||
let trimmed = entry.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// JDT resolves relative library globs against its own root, which may be a submodule.
|
||||
Some(root.join(trimmed).to_string_lossy().replace('\\', "/"))
|
||||
};
|
||||
let mut include: Vec<String> = Vec::new();
|
||||
let mut sources = serde_json::Map::new();
|
||||
for lib in &structure.libraries {
|
||||
let class_entries: Vec<String> = lib.classes.iter().filter_map(|c| normalize(c)).collect();
|
||||
let source_entries: Vec<String> = lib.sources.iter().filter_map(|s| normalize(s)).collect();
|
||||
for c in &class_entries {
|
||||
if !include.contains(c) {
|
||||
include.push(c.clone());
|
||||
}
|
||||
}
|
||||
for (class, source) in class_entries.iter().zip(&source_entries) {
|
||||
sources.insert(class.clone(), json!(source));
|
||||
}
|
||||
}
|
||||
for facet in &structure.facets {
|
||||
if facet.kind == "web" && root.join(&facet.module) == server_root {
|
||||
let web_root = facet.web_root.trim();
|
||||
let web_root = if web_root.is_empty() { "src/main/webapp" } else { web_root };
|
||||
if let Some(path) = normalize(web_root) {
|
||||
let glob = format!("{}/WEB-INF/lib/**/*.jar", path.trim_end_matches('/'));
|
||||
if !include.contains(&glob) {
|
||||
include.push(glob);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if output_path.is_none() && include.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut settings = cfg.settings.take().unwrap_or_else(|| json!({}));
|
||||
if let Some(output) = output_path {
|
||||
settings["java"]["project"]["outputPath"] = json!(output);
|
||||
}
|
||||
if !include.is_empty() {
|
||||
let existing = &settings["java"]["project"]["referencedLibraries"];
|
||||
let mut libraries = if existing.is_object() {
|
||||
existing.clone()
|
||||
} else if existing.is_array() {
|
||||
json!({ "include": existing })
|
||||
} else {
|
||||
json!({ "include": ["lib/**/*.jar"] })
|
||||
};
|
||||
let mut entries = libraries["include"].as_array().cloned().unwrap_or_default();
|
||||
for path in include {
|
||||
let path = json!(path);
|
||||
if !entries.contains(&path) {
|
||||
entries.push(path);
|
||||
}
|
||||
}
|
||||
libraries["include"] = json!(entries);
|
||||
if !sources.is_empty() {
|
||||
let mut attachments = libraries["sources"].as_object().cloned().unwrap_or_default();
|
||||
attachments.extend(sources);
|
||||
libraries["sources"] = json!(attachments);
|
||||
}
|
||||
settings["java"]["project"]["referencedLibraries"] = libraries;
|
||||
}
|
||||
cfg.settings = Some(settings);
|
||||
}
|
||||
|
||||
pub(crate) fn source_paths_removed(
|
||||
current: &ide_language::LanguageServerConfig,
|
||||
next: &ide_language::LanguageServerConfig,
|
||||
@@ -104,11 +219,13 @@ fn compiler_defaults(cfg: &ide_language::LanguageServerConfig) -> Value {
|
||||
let Ok(prefs) = std::fs::read_to_string(path) else { return json!({}) };
|
||||
let mut options = serde_json::Map::new();
|
||||
for line in prefs.lines() {
|
||||
if let Some((key, value)) = line.split_once('=')
|
||||
&& ACCESS_OPTIONS.contains(&key.trim())
|
||||
&& ["error", "warning", "info", "ignore"].contains(&value.trim())
|
||||
{
|
||||
options.insert(key.trim().into(), json!(value.trim()));
|
||||
if let Some((key, value)) = line.split_once('=') {
|
||||
let (k, v) = (key.trim(), value.trim());
|
||||
if (ACCESS_OPTIONS.contains(&k) && ["error", "warning", "info", "ignore"].contains(&v))
|
||||
|| (LEVEL_OPTIONS.contains(&k) && ["1.8", "11", "17", "21", "25"].contains(&v))
|
||||
{
|
||||
options.insert(k.into(), json!(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Object(options)
|
||||
@@ -978,4 +1095,110 @@ mod tests {
|
||||
assert!(missing_bundle(&IdeError::invalid("No delegateCommandHandler for vscode.java.startDebugSession")));
|
||||
assert!(!missing_bundle(&e));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configure_project_structure_sets_output_referenced_libraries_and_web_facets() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let lib_dir = root.join("lib");
|
||||
std::fs::create_dir_all(&lib_dir).unwrap();
|
||||
let jar_path = lib_dir.join("helper.jar");
|
||||
let src_path = lib_dir.join("helper-sources.jar");
|
||||
std::fs::write(&jar_path, b"").unwrap();
|
||||
std::fs::write(&src_path, b"").unwrap();
|
||||
|
||||
let mut structure = ide_core::settings::ProjectStructureSettings {
|
||||
compiler_output: "out/production".into(),
|
||||
libraries: vec![ide_core::settings::ProjectLibrary {
|
||||
name: "helper".into(),
|
||||
classes: vec!["lib/helper.jar".into()],
|
||||
sources: vec!["lib/helper-sources.jar".into()],
|
||||
javadocs: vec![],
|
||||
scope: ide_core::settings::DependencyScope::Compile,
|
||||
}],
|
||||
facets: vec![ide_core::settings::FacetConfig {
|
||||
kind: "web".into(),
|
||||
module: ".".into(),
|
||||
descriptor: "src/main/webapp/WEB-INF/web.xml".into(),
|
||||
web_root: "src/main/webapp".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
structure.modules.insert(
|
||||
"submod".into(),
|
||||
ide_core::settings::ModuleStructureSettings {
|
||||
compiler_output: "submod/classes".into(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let mut cfg: ide_language::LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap();
|
||||
configure_project_structure(&mut cfg, root, root, &structure);
|
||||
|
||||
let settings = cfg.settings.as_ref().unwrap();
|
||||
assert_eq!(settings.pointer("/java/project/outputPath").and_then(Value::as_str), Some("out/production"));
|
||||
let include = settings
|
||||
.pointer("/java/project/referencedLibraries/include")
|
||||
.and_then(Value::as_array)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.collect::<Vec<_>>();
|
||||
assert!(include.iter().any(|p| p.ends_with("lib/helper.jar")));
|
||||
assert!(include.iter().any(|p| p.ends_with("src/main/webapp/WEB-INF/lib/**/*.jar")));
|
||||
let sources = settings.pointer("/java/project/referencedLibraries/sources").and_then(Value::as_object).unwrap();
|
||||
assert!(sources.values().any(|v| v.as_str().is_some_and(|s| s.ends_with("lib/helper-sources.jar"))));
|
||||
|
||||
// Module-specific compiler output overrides project-wide compiler output.
|
||||
let mut sub_cfg: ide_language::LanguageServerConfig =
|
||||
toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap();
|
||||
configure_project_structure(&mut sub_cfg, root, &root.join("submod"), &structure);
|
||||
assert_eq!(
|
||||
sub_cfg.settings.as_ref().unwrap().pointer("/java/project/outputPath").and_then(Value::as_str),
|
||||
Some("classes")
|
||||
);
|
||||
let sub_settings = sub_cfg.settings.as_ref().unwrap();
|
||||
let libs = sub_settings.pointer("/java/project/referencedLibraries/include").unwrap().as_array().unwrap();
|
||||
assert!(libs.contains(&json!(root.join("lib/helper.jar").to_string_lossy().replace('\\', "/"))));
|
||||
assert!(!libs.iter().any(|v| v.as_str().is_some_and(|p| p.contains("WEB-INF"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_libraries_preserve_existing_globs_exclusions_and_source_attachments() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut cfg: ide_language::LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap();
|
||||
cfg.settings = Some(json!({"java": {"project": {"referencedLibraries": {
|
||||
"include": ["vendor/**/*.jar"], "exclude": ["vendor/old.jar"],
|
||||
"sources": {"vendor/base.jar": "vendor/base-sources.jar"}
|
||||
}}}}));
|
||||
let structure = ide_core::settings::ProjectStructureSettings {
|
||||
libraries: vec![ide_core::settings::ProjectLibrary {
|
||||
classes: vec!["lib/new.jar".into()],
|
||||
sources: vec!["lib/new-sources.jar".into()],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
configure_project_structure(&mut cfg, tmp.path(), tmp.path(), &structure);
|
||||
let libraries = cfg.settings.as_ref().unwrap().pointer("/java/project/referencedLibraries").unwrap();
|
||||
assert_eq!(libraries["include"][0], "vendor/**/*.jar");
|
||||
assert_eq!(libraries["exclude"], json!(["vendor/old.jar"]));
|
||||
assert_eq!(libraries["sources"]["vendor/base.jar"], "vendor/base-sources.jar");
|
||||
assert_eq!(libraries["sources"].as_object().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_output_inside_the_module_becomes_relative() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut cfg: ide_language::LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap();
|
||||
let mut structure = ide_core::settings::ProjectStructureSettings {
|
||||
compiler_output: tmp.path().join("out").to_string_lossy().into_owned(),
|
||||
..Default::default()
|
||||
};
|
||||
configure_project_structure(&mut cfg, tmp.path(), tmp.path(), &structure);
|
||||
assert_eq!(cfg.settings.as_ref().unwrap().pointer("/java/project/outputPath"), Some(&json!("out")));
|
||||
structure.compiler_output = tmp.path().parent().unwrap().join("elsewhere").to_string_lossy().into_owned();
|
||||
configure_project_structure(&mut cfg, tmp.path(), tmp.path(), &structure);
|
||||
assert!(cfg.launch_error.as_deref().unwrap().contains("inside its module"));
|
||||
}
|
||||
}
|
||||
+20
-16
@@ -1385,14 +1385,8 @@ impl LspManager {
|
||||
let mut first = Vec::new();
|
||||
if method == "textDocument/implementation" {
|
||||
let (locs, complete) = self.java_source_implementations(snap, t, pos, cancel).await;
|
||||
// A type declared in the sources has no implementations in the dependencies:
|
||||
// none found there is the whole answer, without a type hierarchy over every jar.
|
||||
if complete {
|
||||
if !locs.is_empty() {
|
||||
return Ok(locs);
|
||||
}
|
||||
out.value = Some(Vec::new());
|
||||
continue;
|
||||
if complete && !locs.is_empty() {
|
||||
return Ok(locs);
|
||||
}
|
||||
if let Some(partial) = partial
|
||||
&& !locs.is_empty()
|
||||
@@ -1450,9 +1444,9 @@ impl LspManager {
|
||||
self.references_streaming(doc, pos, include_declaration, project_wide, true, cancel, None).await
|
||||
}
|
||||
|
||||
/// [`Self::references`]. Java: without `libraries`, only the workspace's sources are
|
||||
/// searched (`libraries_skipped` when `project_wide`); with it and `project_wide`, those
|
||||
/// found there arrive on `partial` before the dependencies and the JDK are searched.
|
||||
/// [`Self::references`]. Java, `project_wide`: without `libraries`, only the
|
||||
/// workspace's sources are searched (`libraries_skipped`); with it, those found
|
||||
/// there arrive on `partial` before the dependencies and the JDK are searched.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn references_streaming(
|
||||
&self,
|
||||
@@ -1481,7 +1475,20 @@ impl LspManager {
|
||||
};
|
||||
let native =
|
||||
self.native_references(doc, pos, include_declaration, project_wide, libraries, cancel.clone(), partial);
|
||||
let (configuration, result) = futures::join!(configuration, native);
|
||||
futures::pin_mut!(configuration, native);
|
||||
let (configuration, result) = match futures::future::select(configuration, native).await {
|
||||
futures::future::Either::Left((configuration, native)) => (configuration, native.await),
|
||||
futures::future::Either::Right((result, configuration)) => {
|
||||
// The configuration index is rebuilt after Java changes, which takes seconds in a
|
||||
// project with many dependencies: the usages found meanwhile are shown first.
|
||||
if let (Some(partial), Ok(found)) = (partial, &result)
|
||||
&& !found.locations.is_empty()
|
||||
{
|
||||
partial(found.clone());
|
||||
}
|
||||
(configuration.await, result)
|
||||
}
|
||||
};
|
||||
let configuration = configuration?;
|
||||
let mut result = result?;
|
||||
if let Some(configuration) = configuration {
|
||||
@@ -1605,10 +1612,7 @@ impl LspManager {
|
||||
};
|
||||
scope::Widened { first: all, second: Some(found), incomplete: sources.incomplete }
|
||||
} else {
|
||||
// Bulk probes (usage counts, Safe Delete) ask about the sources' own symbols,
|
||||
// which no dependency uses: searching every jar and the JDK only adds seconds
|
||||
// per symbol and delays the searches the user asks for.
|
||||
let first = query(!libraries).await?;
|
||||
let first = query(false).await?;
|
||||
let empty = first.is_empty();
|
||||
scope::Widened::only(first, empty, &t.server)
|
||||
};
|
||||
|
||||
@@ -1891,11 +1891,10 @@ async fn java_implementations_come_from_sources_first() {
|
||||
.collect::<Value>()
|
||||
};
|
||||
*s.responder.lock() = Some(Arc::new(move |_, method, params| match method {
|
||||
// Lines 0 and 1 are declared in the sources; 2 and 3 in a dependency, which implements 2 on line 3.
|
||||
// Line 0 is declared in the sources; line 2 in a dependency, which implements it on line 3.
|
||||
"workspace/executeCommand" => Some(match params["arguments"][1].as_u64() {
|
||||
Some(0) => json!({ "locations": at(&[1]), "complete": true }),
|
||||
Some(2) => json!({ "locations": at(&[1]), "complete": false }),
|
||||
Some(3) => json!({ "locations": [], "complete": false }),
|
||||
_ => json!({ "locations": [], "complete": true }),
|
||||
}),
|
||||
"textDocument/implementation" => Some(at(&[3, 1])),
|
||||
@@ -1926,14 +1925,7 @@ async fn java_implementations_come_from_sources_first() {
|
||||
assert_eq!(lines(&found), vec![3, 1]);
|
||||
assert_eq!(s.requested("textDocument/implementation").len(), 2);
|
||||
|
||||
// Declared in the sources and implemented nowhere there: no search through the dependencies.
|
||||
let found = h.mgr.goto(doc, Pos::new(1, 6), GotoKind::Implementation).await.unwrap();
|
||||
assert!(found.is_empty());
|
||||
let markers = h.mgr.implementations(doc, &[Pos::new(1, 6)], Default::default()).await.unwrap();
|
||||
assert!(markers[0].is_empty());
|
||||
assert_eq!(s.requested("textDocument/implementation").len(), 2);
|
||||
|
||||
// None in the sources of a dependency's type: the standard request, with the dependencies.
|
||||
// None in the sources: the standard request, with the dependencies.
|
||||
let partial = Mutex::new(Vec::new());
|
||||
let found = h
|
||||
.mgr
|
||||
@@ -3322,17 +3314,9 @@ async fn java_usages_in_the_sources_arrive_before_the_dependencies() {
|
||||
let scopes: Vec<_> =
|
||||
s.requested("workspace/executeCommand")[3..].iter().map(|p| p["arguments"].get(4).cloned()).collect();
|
||||
assert_eq!(scopes, [Some(json!("sources")), Some(json!("sources"))]);
|
||||
// Scoped probes with the libraries search everything at once.
|
||||
// Scoped probes (code lenses) search everything at once.
|
||||
let scoped = h.mgr.references(doc, Pos::new(0, 5), true, false, Default::default()).await.unwrap();
|
||||
assert_eq!(scoped.locations.len(), 2);
|
||||
// Usage counts and Safe Delete (no libraries): the sources alone, not widened.
|
||||
let scoped =
|
||||
h.mgr.references_streaming(doc, Pos::new(0, 5), true, false, false, Default::default(), None).await.unwrap();
|
||||
assert_eq!(scoped.locations.len(), 1);
|
||||
assert!(scoped.locations.iter().all(|l| l.uri.is_none()));
|
||||
let scopes: Vec<_> =
|
||||
s.requested("workspace/executeCommand")[5..].iter().map(|p| p["arguments"].get(4).cloned()).collect();
|
||||
assert_eq!(scopes, [None, Some(json!("sources"))]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
@@ -4144,6 +4128,52 @@ async fn configuration_usages_merge_native_and_unsaved_profiles_without_sibling_
|
||||
assert_eq!(refs.incomplete, None);
|
||||
}
|
||||
|
||||
/// The configuration index is rebuilt after Java changes, which can take seconds: Show Usages
|
||||
/// shows the Java usages meanwhile, then adds the configuration ones.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_usages_arrive_before_configuration_usages() {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages", "openidea.java.configuration"]});
|
||||
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
|
||||
let s = h.launcher.state("jdtls");
|
||||
let yaml = h.root.join("application.yml");
|
||||
std::fs::write(&yaml, "app:\n target: 1\n").unwrap();
|
||||
let property = json!({"uri": uri_of(&yaml), "range": {
|
||||
"start": {"line": 1, "character": 2}, "end": {"line": 1, "character": 8}}});
|
||||
let gate = Arc::new(Gate::default());
|
||||
let held = gate.clone();
|
||||
*s.responder.lock() = Some(Arc::new(move |s, method, params| {
|
||||
(method == "workspace/executeCommand").then(|| {
|
||||
if params["command"] == "openidea.java.configuration" {
|
||||
held.hold();
|
||||
return json!({"locations": [property.clone()], "keys": [{"name": "app.target", "relaxed": true}], "roots": []});
|
||||
}
|
||||
let docs = s.docs.lock();
|
||||
let rows = docs.iter().flat_map(|(uri, text)| occurrences(uri, text));
|
||||
Value::Array(rows.map(|location| json!({"location": location, "kind": "unknown"})).collect())
|
||||
})
|
||||
}));
|
||||
let (doc, a) = h.open("a.java", "int target;");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
eventually(|| s.docs.lock().len() == 1).await;
|
||||
let partials = Mutex::new(Vec::<References>::new());
|
||||
let push = |r| partials.lock().push(r);
|
||||
let search = h.mgr.references_streaming(doc, Pos::new(0, 5), true, true, false, Default::default(), Some(&push));
|
||||
let release = async {
|
||||
eventually(|| !partials.lock().is_empty()).await;
|
||||
assert!(gate.entered(), "shown before the configuration lookup started");
|
||||
gate.open();
|
||||
};
|
||||
let (refs, ()) = tokio::join!(search, release);
|
||||
let refs = refs.unwrap();
|
||||
let partials = partials.into_inner();
|
||||
assert_eq!(partials.len(), 1);
|
||||
let paths = |r: &References| r.locations.iter().map(|l| l.path.to_path_buf()).collect::<Vec<_>>();
|
||||
assert_eq!(paths(&partials[0]), std::slice::from_ref(&a));
|
||||
assert_eq!(partials[0].classified.len(), 1);
|
||||
assert_eq!(paths(&refs), [a, yaml]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn configuration_extension_failure_preserves_native_java_navigation() {
|
||||
let mut caps = incremental_caps();
|
||||
|
||||
@@ -20,6 +20,6 @@ pub use runtime::{
|
||||
API_VERSION, CANCELLED, CallCancel, CallError, GuestError, HostApi, PluginEngine, Runtime, RuntimeSetup, check_api,
|
||||
component_api, host_api,
|
||||
};
|
||||
pub use sandbox::{MAX_READ_BYTES, read_text, resolve_in_root};
|
||||
pub use sandbox::{MAX_READ_BYTES, inside_root, read_text, resolve_in_root, resolve_new_in_root, resolve_real};
|
||||
pub use types::*;
|
||||
pub use wasmtime::component::Component;
|
||||
@@ -4,19 +4,20 @@
|
||||
//! themselves are in `wit_host`.
|
||||
|
||||
use crate::types::{
|
||||
CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, PluginHover, ProviderDocument, SemanticToken,
|
||||
CodeAction, CommitDraft, CommitProblem, CompletionItem, FileOperation, InlineSuggestion, LineMarker, Location,
|
||||
PluginHover, PluginInlayHint, ProviderDocument, SemanticToken,
|
||||
};
|
||||
use crate::types::{
|
||||
EventKind, HostError, HttpRequest, HttpResponse, Invocation, Limits, NotificationLevel, ProcessOutput, Prompt,
|
||||
RepositoryStatus, StatusItem, SyntaxFold, SyntaxSymbol, SyntaxToken, WorkbenchEvent,
|
||||
};
|
||||
use crate::types::{
|
||||
FieldValue, ServiceLog, ServiceLogToggle, ServicePane, ServiceTab, View, ViewBody, ViewButton, ViewEvent,
|
||||
ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport,
|
||||
FieldValue, ServiceLog, ServiceLogToggle, ServicePane, ServiceTab, TableSort, View, ViewBody, ViewButton,
|
||||
ViewEvent, ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport,
|
||||
};
|
||||
use crate::wit_host::{
|
||||
bindings, hover_bindings, indexing_bindings, providers_bindings, semantic_bindings, services_bindings,
|
||||
syntax_bindings, views_bindings,
|
||||
bindings, commit_checks_bindings, details_bindings, endpoints_bindings, file_actions_bindings, hover_bindings,
|
||||
indexing_bindings, inlay_bindings, providers_bindings, semantic_bindings, syntax_bindings, views_bindings,
|
||||
};
|
||||
use ide_core::CancellationToken;
|
||||
use ide_core::ipc_path::to_ipc;
|
||||
@@ -33,12 +34,16 @@ use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
|
||||
|
||||
use bindings::openidea::plugin::events as wit_events;
|
||||
use bindings::openidea::plugin::types as wit_types;
|
||||
use commit_checks_bindings::exports::openidea::plugin::commit_checker as wit_cc;
|
||||
use details_bindings::exports::openidea::plugin::detail_provider as wit_detail;
|
||||
use details_bindings::openidea::plugin::detail_types as wit_dt;
|
||||
use endpoints_bindings::exports::openidea::plugin::endpoints_provider as wit_endpoints;
|
||||
use file_actions_bindings::exports::openidea::plugin::file_actions as wit_fa;
|
||||
use hover_bindings::exports::openidea::plugin::hover_provider as wit_hover;
|
||||
use indexing_bindings::exports::openidea::plugin::document_indexer as wit_index;
|
||||
use inlay_bindings::exports::openidea::plugin::inlay_hints_provider as wit_inlay;
|
||||
use providers_bindings::exports::openidea::plugin::providers as wit_prov;
|
||||
use semantic_bindings::exports::openidea::plugin::semantic_tokens_provider as wit_sem;
|
||||
use services_bindings::exports::openidea::plugin::service_provider as wit_svc;
|
||||
use services_bindings::openidea::plugin::service_types as wit_st;
|
||||
use syntax_bindings::exports::openidea::plugin::syntax_provider as wit_syntax;
|
||||
use views_bindings::exports::openidea::plugin::view_provider as wit_view;
|
||||
use views_bindings::openidea::plugin::view_types as wit_vt;
|
||||
@@ -56,7 +61,15 @@ pub const CANCELLED: &str = "was cancelled";
|
||||
/// which routes notifications and status items to windows and edits to open
|
||||
/// documents. Capabilities are already checked when these are called.
|
||||
pub trait HostApi: Send + Sync {
|
||||
fn notify(&self, client: Option<&str>, level: NotificationLevel, title: String, body: String);
|
||||
/// Shows a notification; `actions` name commands the plugin declares.
|
||||
fn notify(
|
||||
&self,
|
||||
client: Option<&str>,
|
||||
level: NotificationLevel,
|
||||
title: String,
|
||||
body: String,
|
||||
actions: Vec<crate::NotificationAction>,
|
||||
) -> Result<(), HostError>;
|
||||
fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
|
||||
let _ = (client, text);
|
||||
Ok(())
|
||||
@@ -65,6 +78,10 @@ pub trait HostApi: Send + Sync {
|
||||
let _ = (client, url);
|
||||
Ok(())
|
||||
}
|
||||
fn open_project(&self, client: Option<&str>, path: String, new_window: bool) -> Result<(), HostError> {
|
||||
let _ = (client, path, new_window);
|
||||
Err(HostError::Invalid("opening projects is unavailable".into()))
|
||||
}
|
||||
/// Shows `prompt` in `client`'s window and waits for the answer until the
|
||||
/// user gives one or `cancel` fires: the chosen keys (`QuickPick`), the
|
||||
/// text (`Input`, one element) or any value (`Confirm`: OK); `None` when
|
||||
@@ -93,6 +110,17 @@ pub trait HostApi: Send + Sync {
|
||||
}
|
||||
/// Roots of the open projects.
|
||||
fn projects(&self) -> Vec<std::path::PathBuf>;
|
||||
/// The most specific open project containing a document (including a new file).
|
||||
fn project_of(&self, path: &Path) -> Option<std::path::PathBuf> {
|
||||
if !path.is_absolute() {
|
||||
return None;
|
||||
}
|
||||
let real = crate::resolve_real(path);
|
||||
self.projects()
|
||||
.into_iter()
|
||||
.filter(|root| real.starts_with(crate::resolve_real(root)))
|
||||
.max_by_key(|root| root.components().count())
|
||||
}
|
||||
/// Unsaved text of an open editor's file.
|
||||
fn buffer_text(&self, path: &Path) -> Option<String>;
|
||||
/// Version and text of the open document at `path`.
|
||||
@@ -115,6 +143,22 @@ pub trait HostApi: Send + Sync {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn vcs_fetch(&self, root: &Path, cancel: &CancellationToken) -> Result<(), HostError>;
|
||||
/// The commit being prepared in `client`'s window (`None`: no repository),
|
||||
/// waiting for the window until `cancel` fires.
|
||||
fn pending_commit(
|
||||
&self,
|
||||
client: Option<&str>,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Option<CommitDraft>, HostError> {
|
||||
let _ = (client, cancel);
|
||||
Ok(None)
|
||||
}
|
||||
/// Replaces the commit message of `client`'s window if its draft is still at
|
||||
/// `revision` (else [`HostError::Stale`]).
|
||||
fn set_commit_message(&self, client: Option<&str>, revision: u64, message: String) -> Result<(), HostError> {
|
||||
let _ = (client, revision, message);
|
||||
Err(HostError::Invalid("no commit is being prepared".into()))
|
||||
}
|
||||
/// Subscribes to (or unsubscribes from) a kind of workbench event.
|
||||
/// Project files matching `glob` (root-relative), at most `limit`.
|
||||
fn find_files(&self, root: &Path, glob: &str, limit: usize) -> Result<Vec<String>, HostError>;
|
||||
@@ -133,7 +177,11 @@ pub trait HostApi: Send + Sync {
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<ProcessOutput, HostError>;
|
||||
fn subscribe(&self, kind: EventKind, on: bool);
|
||||
/// Delivers every event of `kind`, dropping its path filter.
|
||||
fn subscribe(&self, kind: EventKind);
|
||||
/// Delivers `kind` (`file-opened`, `file-saved`) only for paths matching `globs`.
|
||||
fn subscribe_paths(&self, kind: &str, globs: Vec<String>) -> Result<(), HostError>;
|
||||
fn unsubscribe(&self, kind: &str);
|
||||
/// Starts a progress indicator; cancelling it from the UI cancels `cancel`.
|
||||
fn task_begin(&self, client: Option<&str>, title: String, cancellable: bool, cancel: CancellationToken) -> u64;
|
||||
fn task_report(&self, id: u64, done: u32, total: Option<u32>, message: Option<String>);
|
||||
@@ -154,6 +202,8 @@ pub trait HostApi: Send + Sync {
|
||||
cwd: Option<&Path>,
|
||||
) -> Result<(), HostError>;
|
||||
fn open_location(&self, client: Option<&str>, path: &Path, line: u32, column: u32) -> Result<(), HostError>;
|
||||
/// The IDE's UI language as a BCP 47 tag.
|
||||
fn locale(&self) -> String;
|
||||
/// A setting of the plugin as JSON (project over global over the schema default).
|
||||
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String>;
|
||||
}
|
||||
@@ -522,10 +572,14 @@ struct Exports {
|
||||
syntax: Option<wit_syntax::Guest>,
|
||||
providers: Option<wit_prov::Guest>,
|
||||
indexer: Option<wit_index::Guest>,
|
||||
file_actions: Option<wit_fa::Guest>,
|
||||
views: Option<wit_view::Guest>,
|
||||
semantic: Option<wit_sem::Guest>,
|
||||
services: Option<wit_svc::Guest>,
|
||||
details: Option<wit_detail::Guest>,
|
||||
hover: Option<wit_hover::Guest>,
|
||||
commit_checks: Option<wit_cc::Guest>,
|
||||
inlay: Option<wit_inlay::Guest>,
|
||||
endpoints: Option<wit_endpoints::Guest>,
|
||||
}
|
||||
|
||||
/// Most results one syntax call may return (more are dropped).
|
||||
@@ -603,11 +657,15 @@ impl Runtime {
|
||||
let plugin_indices = bindings::PluginIndices::new(&pre).map_err(api_hint)?;
|
||||
let syntax_indices = wit_syntax::GuestIndices::new(&pre).ok();
|
||||
let index_indices = wit_index::GuestIndices::new(&pre).ok();
|
||||
let file_action_indices = wit_fa::GuestIndices::new(&pre).ok();
|
||||
let provider_indices = wit_prov::GuestIndices::new(&pre).ok();
|
||||
let view_indices = wit_view::GuestIndices::new(&pre).ok();
|
||||
let semantic_indices = wit_sem::GuestIndices::new(&pre).ok();
|
||||
let service_indices = wit_svc::GuestIndices::new(&pre).ok();
|
||||
let detail_indices = wit_detail::GuestIndices::new(&pre).ok();
|
||||
let hover_indices = wit_hover::GuestIndices::new(&pre).ok();
|
||||
let commit_check_indices = wit_cc::GuestIndices::new(&pre).ok();
|
||||
let inlay_indices = wit_inlay::GuestIndices::new(&pre).ok();
|
||||
let endpoint_indices = wit_endpoints::GuestIndices::new(&pre).ok();
|
||||
let instance = pre.instantiate(&mut store).map_err(api_hint)?;
|
||||
let plugin = plugin_indices.load(&mut store, &instance).map_err(api_hint)?;
|
||||
let syntax = match syntax_indices {
|
||||
@@ -622,6 +680,10 @@ impl Runtime {
|
||||
Some(i) => Some(i.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
let file_actions = match file_action_indices {
|
||||
Some(i) => Some(i.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
let views = match view_indices {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
@@ -630,7 +692,7 @@ impl Runtime {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
let services = match service_indices {
|
||||
let details = match detail_indices {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
@@ -638,9 +700,34 @@ impl Runtime {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
let commit_checks = match commit_check_indices {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
let inlay = match inlay_indices {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
let endpoints = match endpoint_indices {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
Ok(Self {
|
||||
store,
|
||||
exports: Exports { plugin, syntax, providers, views, indexer, semantic, services, hover },
|
||||
exports: Exports {
|
||||
plugin,
|
||||
syntax,
|
||||
providers,
|
||||
views,
|
||||
indexer,
|
||||
semantic,
|
||||
details,
|
||||
hover,
|
||||
commit_checks,
|
||||
inlay,
|
||||
endpoints,
|
||||
file_actions,
|
||||
},
|
||||
limits: setup.limits,
|
||||
version: setup.version,
|
||||
})
|
||||
@@ -822,9 +909,23 @@ impl Runtime {
|
||||
selection: ide_core::Range,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<CodeAction>, CallError> {
|
||||
let d = doc_to_wit(doc);
|
||||
let actions = self.provider_call(timeout, |p, s| p.call_code_actions(s, &d, range_to_wit(selection)))?;
|
||||
Ok(actions.into_iter().take(MAX_PROVIDER_ITEMS).map(code_action_from_wit).collect())
|
||||
let mut out = Vec::new();
|
||||
if self.exports.providers.is_some() {
|
||||
let d = doc_to_wit(doc);
|
||||
let actions = self.provider_call(timeout, |p, s| p.call_code_actions(s, &d, range_to_wit(selection)))?;
|
||||
out.extend(actions.into_iter().take(MAX_PROVIDER_ITEMS).map(code_action_from_wit));
|
||||
}
|
||||
if self.exports.file_actions.is_some() {
|
||||
let d = file_action_doc(doc);
|
||||
let actions = self.call(Invocation::default(), timeout, |e, store| {
|
||||
e.file_actions.as_ref().expect("checked").call_code_actions(store, &d, range_to_wit(selection))
|
||||
})?;
|
||||
out.extend(actions.into_iter().take(MAX_PROVIDER_ITEMS).map(file_action_from_wit));
|
||||
}
|
||||
if out.is_empty() && self.exports.providers.is_none() {
|
||||
return Err(CallError::missing_export("providers"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The edits of an action `provide_code_actions` returned with `data`.
|
||||
@@ -834,6 +935,17 @@ impl Runtime {
|
||||
action: &CodeAction,
|
||||
timeout: Duration,
|
||||
) -> Result<CodeAction, CallError> {
|
||||
if action.file_action {
|
||||
if self.exports.file_actions.is_none() {
|
||||
return Err(CallError::missing_export("file-actions"));
|
||||
}
|
||||
let d = file_action_doc(doc);
|
||||
let a = file_action_to_wit(action);
|
||||
let resolved = self.call(Invocation::default(), timeout, |e, store| {
|
||||
e.file_actions.as_ref().expect("checked").call_resolve(store, &d, &a)
|
||||
})?;
|
||||
return resolved.map(file_action_from_wit).map_err(|e| CallError::Guest(e.into()));
|
||||
}
|
||||
let d = doc_to_wit(doc);
|
||||
let a = code_action_to_wit(action);
|
||||
let resolved = self.provider_call(timeout, |p, s| p.call_resolve_code_action(s, &d, &a))?;
|
||||
@@ -921,6 +1033,40 @@ impl Runtime {
|
||||
.map(|h| PluginHover { markdown: clip(h.markdown, MAX_HOVER_BYTES), range: h.range.map(range_from_wit) }))
|
||||
}
|
||||
|
||||
pub fn provide_inlay_hints(
|
||||
&mut self,
|
||||
doc: &ProviderDocument,
|
||||
range: ide_core::Range,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<PluginInlayHint>, CallError> {
|
||||
if self.exports.inlay.is_none() {
|
||||
return Err(CallError::missing_export("inlay-hints-provider"));
|
||||
}
|
||||
let d = wit_inlay::Document {
|
||||
path: to_ipc(&doc.path),
|
||||
language: doc.language.clone(),
|
||||
version: doc.version,
|
||||
text: doc.text.clone(),
|
||||
};
|
||||
let r = wit_types::Range { start: pos_to_wit(range.start), end: pos_to_wit(range.end) };
|
||||
let hints = self.call(Invocation::default(), timeout, |e, store| {
|
||||
e.inlay.as_ref().expect("checked").call_inlay_hints(store, &d, r)
|
||||
})?;
|
||||
Ok(hints
|
||||
.into_iter()
|
||||
.filter(|h| !h.label.trim().is_empty())
|
||||
.take(MAX_PROVIDER_ITEMS)
|
||||
.map(|h| PluginInlayHint {
|
||||
position: ide_core::Pos::new(h.position.line, h.position.character),
|
||||
label: clip(h.label, 256),
|
||||
kind: clip(h.kind, 32),
|
||||
tooltip: h.tooltip.map(|t| clip(t, 1024)),
|
||||
padding_left: h.padding_left,
|
||||
padding_right: h.padding_right,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn activate(&mut self) -> Result<(), CallError> {
|
||||
let data = self.store.data();
|
||||
let ctx = wit_types::Context {
|
||||
@@ -986,6 +1132,37 @@ impl Runtime {
|
||||
}
|
||||
}
|
||||
|
||||
/// Project routes, with an explicit project invocation and background budget.
|
||||
pub fn endpoints(&mut self, invocation: Invocation, timeout: Duration) -> Result<Vec<crate::Endpoint>, CallError> {
|
||||
if self.exports.endpoints.is_none() {
|
||||
return Err(CallError::missing_export("endpoints-provider"));
|
||||
}
|
||||
let result = self.call(invocation, timeout, |e, store| {
|
||||
let ctx = store.data_mut().new_invocation()?;
|
||||
let r = e
|
||||
.endpoints
|
||||
.as_ref()
|
||||
.expect("checked")
|
||||
.call_endpoints(&mut *store, wasmtime::component::Resource::new_borrow(ctx.rep()));
|
||||
store.data_mut().drop_invocation(ctx);
|
||||
r
|
||||
})?;
|
||||
let entries = result.map_err(|e| CallError::Guest(e.into()))?;
|
||||
if entries.len() > MAX_PROVIDER_ITEMS {
|
||||
return Err(CallError::Guest(GuestError::Invalid("too many endpoints".into())));
|
||||
}
|
||||
Ok(entries
|
||||
.into_iter()
|
||||
.map(|e| crate::Endpoint {
|
||||
method: e.method,
|
||||
path: e.path,
|
||||
framework: e.framework,
|
||||
handler: e.handler,
|
||||
location: location_from_wit(e.location),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The Services pane of node `key` of the plugin's services view `view`.
|
||||
pub fn service_detail(
|
||||
&mut self,
|
||||
@@ -993,13 +1170,13 @@ impl Runtime {
|
||||
key: &str,
|
||||
invocation: Invocation,
|
||||
) -> Result<Vec<ServiceTab>, CallError> {
|
||||
if self.exports.services.is_none() {
|
||||
return Err(CallError::missing_export("service-provider"));
|
||||
if self.exports.details.is_none() {
|
||||
return Err(CallError::missing_export("detail-provider"));
|
||||
}
|
||||
let result = self.call(invocation, self.limits.timeout, |e, store| {
|
||||
let ctx = store.data_mut().new_invocation()?;
|
||||
let services = e.services.as_ref().expect("checked");
|
||||
let r = services.call_detail(&mut *store, view, key, wasmtime::component::Resource::new_borrow(ctx.rep()));
|
||||
let details = e.details.as_ref().expect("checked");
|
||||
let r = details.call_detail(&mut *store, view, key, wasmtime::component::Resource::new_borrow(ctx.rep()));
|
||||
store.data_mut().drop_invocation(ctx);
|
||||
r
|
||||
})?;
|
||||
@@ -1009,6 +1186,30 @@ impl Runtime {
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the plugin's before-commit check `check` on `draft`.
|
||||
pub fn commit_check(
|
||||
&mut self,
|
||||
check: &str,
|
||||
draft: CommitDraft,
|
||||
invocation: Invocation,
|
||||
) -> Result<Vec<CommitProblem>, CallError> {
|
||||
if self.exports.commit_checks.is_none() {
|
||||
return Err(CallError::missing_export("commit-checker"));
|
||||
}
|
||||
let draft = crate::wit_host::commit_draft_to_wit(draft);
|
||||
let result = self.call(invocation, self.limits.timeout, |e, store| {
|
||||
let ctx = store.data_mut().new_invocation()?;
|
||||
let checks = e.commit_checks.as_ref().expect("checked");
|
||||
let r = checks.call_check(&mut *store, check, &draft, wasmtime::component::Resource::new_borrow(ctx.rep()));
|
||||
store.data_mut().drop_invocation(ctx);
|
||||
r
|
||||
})?;
|
||||
match result {
|
||||
Ok(problems) => Ok(problems.into_iter().take(MAX_COMMIT_PROBLEMS).map(commit_problem_from_wit).collect()),
|
||||
Err(e) => Err(CallError::Guest(e.into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// A user action in a view or custom editor.
|
||||
pub fn view_event(&mut self, view: &str, event: ViewEvent, invocation: Invocation) -> Result<(), CallError> {
|
||||
if self.exports.views.is_none() {
|
||||
@@ -1179,7 +1380,10 @@ fn code_action_from_wit(a: wit_prov::CodeAction) -> CodeAction {
|
||||
.into_iter()
|
||||
.map(|f| (std::path::PathBuf::from(f.path), f.edits.into_iter().map(edit_from_wit).collect()))
|
||||
.collect(),
|
||||
operations: Vec::new(),
|
||||
open: None,
|
||||
data: a.data.map(|d| clip(d, 64 << 10)),
|
||||
file_action: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1203,20 +1407,106 @@ fn code_action_to_wit(a: &CodeAction) -> wit_prov::CodeAction {
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_FILE_OPERATIONS: usize = 1000;
|
||||
|
||||
fn file_action_doc(doc: &ProviderDocument) -> wit_fa::Document {
|
||||
wit_fa::Document {
|
||||
path: to_ipc(&doc.path),
|
||||
language: doc.language.clone(),
|
||||
version: doc.version,
|
||||
text: doc.text.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn file_action_from_wit(a: wit_fa::FileAction) -> CodeAction {
|
||||
CodeAction {
|
||||
title: clip(a.title, MAX_TITLE),
|
||||
kind: a.kind.map(|k| clip(k, 64)),
|
||||
preferred: a.preferred,
|
||||
edits: a
|
||||
.edits
|
||||
.into_iter()
|
||||
.map(|f| (std::path::PathBuf::from(f.path), f.edits.into_iter().map(edit_from_wit).collect()))
|
||||
.collect(),
|
||||
operations: a.operations.into_iter().take(MAX_FILE_OPERATIONS).map(operation_from_wit).collect(),
|
||||
open: a.open.map(std::path::PathBuf::from),
|
||||
data: a.data.map(|d| clip(d, 64 << 10)),
|
||||
file_action: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn file_action_to_wit(a: &CodeAction) -> wit_fa::FileAction {
|
||||
wit_fa::FileAction {
|
||||
title: a.title.clone(),
|
||||
kind: a.kind.clone(),
|
||||
preferred: a.preferred,
|
||||
operations: a
|
||||
.operations
|
||||
.iter()
|
||||
.map(|op| match op {
|
||||
FileOperation::Create { path, text, overwrite } => wit_fa::FileOperation::Create(wit_fa::CreateFile {
|
||||
path: to_ipc(path),
|
||||
text: text.clone(),
|
||||
overwrite: *overwrite,
|
||||
}),
|
||||
FileOperation::Rename { from, to } => {
|
||||
wit_fa::FileOperation::Rename(wit_fa::RenameFile { path: to_ipc(from), new_path: to_ipc(to) })
|
||||
}
|
||||
FileOperation::Delete { path } => {
|
||||
wit_fa::FileOperation::Delete(wit_fa::DeleteFile { path: to_ipc(path) })
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
edits: a
|
||||
.edits
|
||||
.iter()
|
||||
.map(|(path, edits)| wit_fa::FileEdit {
|
||||
path: to_ipc(path),
|
||||
edits: edits
|
||||
.iter()
|
||||
.map(|e| wit_types::TextEdit { range: range_to_wit(e.range), text: e.new_text.clone() })
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
open: a.open.as_deref().map(to_ipc),
|
||||
data: a.data.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn operation_from_wit(op: wit_fa::FileOperation) -> FileOperation {
|
||||
match op {
|
||||
wit_fa::FileOperation::Create(c) => {
|
||||
FileOperation::Create { path: c.path.into(), text: c.text, overwrite: c.overwrite }
|
||||
}
|
||||
wit_fa::FileOperation::Rename(r) => FileOperation::Rename { from: r.path.into(), to: r.new_path.into() },
|
||||
wit_fa::FileOperation::Delete(d) => FileOperation::Delete { path: d.path.into() },
|
||||
}
|
||||
}
|
||||
|
||||
fn opt_clip(s: Option<String>, max: usize) -> Option<String> {
|
||||
s.map(|s| clip(s, max))
|
||||
}
|
||||
|
||||
const MAX_COMMIT_PROBLEMS: usize = 50;
|
||||
|
||||
fn commit_problem_from_wit(p: wit_cc::CommitProblem) -> CommitProblem {
|
||||
let (path, range) = match p.location {
|
||||
Some(l) => (Some(clip(l.path, 4096)), Some(range_from_wit(l.range))),
|
||||
None => (None, None),
|
||||
};
|
||||
CommitProblem { message: clip(p.message, 4096), path, range }
|
||||
}
|
||||
|
||||
const MAX_SERVICE_TABS: usize = 8;
|
||||
const MAX_LOG_TOGGLES: usize = 8;
|
||||
const MAX_LOG_ARGS: usize = 256;
|
||||
const MAX_LOG_ARG: usize = 4096;
|
||||
|
||||
fn service_tab_from_wit(t: wit_st::Tab) -> ServiceTab {
|
||||
fn service_tab_from_wit(t: wit_dt::Tab) -> ServiceTab {
|
||||
let body = match t.body {
|
||||
wit_st::Pane::Message(m) => ServicePane::Message(clip(m, 16 << 10)),
|
||||
wit_st::Pane::Text(text) => ServicePane::Text(clip(text, MAX_TEXT)),
|
||||
wit_st::Pane::Log(log) => {
|
||||
wit_dt::Pane::Message(m) => ServicePane::Message(clip(m, 16 << 10)),
|
||||
wit_dt::Pane::Text(text) => ServicePane::Text(clip(text, MAX_TEXT)),
|
||||
wit_dt::Pane::Log(log) => {
|
||||
let args_fit = |args: &[String]| args.len() <= MAX_LOG_ARGS && args.iter().all(|a| a.len() <= MAX_LOG_ARG);
|
||||
if args_fit(&log.args)
|
||||
&& log.toggles.len() <= MAX_LOG_TOGGLES
|
||||
@@ -1241,12 +1531,54 @@ fn service_tab_from_wit(t: wit_st::Tab) -> ServiceTab {
|
||||
ServiceTab { id: clip(t.id, 64), label: clip(t.label, MAX_TITLE), body }
|
||||
}
|
||||
|
||||
/// A `table` body carried by the `other` case (the sortable variant, plugins/
|
||||
/// README.md §10): the usual table fields as JSON plus `sort`, the column the
|
||||
/// plugin orders by and the direction, or null. Anything unparsable degrades
|
||||
/// like a body this host doesn't know.
|
||||
fn sortable_table_from_json(data: &str) -> Option<ViewTable> {
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Json {
|
||||
#[serde(default)]
|
||||
columns: Vec<String>,
|
||||
#[serde(default)]
|
||||
total_rows: u32,
|
||||
#[serde(default)]
|
||||
first_row: u32,
|
||||
#[serde(default)]
|
||||
rows: Vec<Vec<String>>,
|
||||
#[serde(default)]
|
||||
editable: bool,
|
||||
#[serde(default)]
|
||||
sort: Option<TableSort>,
|
||||
}
|
||||
let json: Json = serde_json::from_str(data).ok()?;
|
||||
Some(ViewTable {
|
||||
columns: json.columns.into_iter().take(1000).map(|c| clip(c, MAX_TITLE)).collect(),
|
||||
total_rows: json.total_rows,
|
||||
first_row: json.first_row,
|
||||
rows: json
|
||||
.rows
|
||||
.into_iter()
|
||||
.take(MAX_TABLE_ROWS as usize)
|
||||
.map(|r| r.into_iter().take(1000).map(|c| clip(c, MAX_CELL)).collect())
|
||||
.collect(),
|
||||
editable: json.editable,
|
||||
sortable: true,
|
||||
sort: json.sort,
|
||||
})
|
||||
}
|
||||
|
||||
fn view_from_wit(v: wit_vt::View) -> View {
|
||||
let body = match v.body {
|
||||
wit_vt::Body::Message(m) => ViewBody::Message(clip(m, 16 << 10)),
|
||||
wit_vt::Body::Other(e) => {
|
||||
ViewBody::Message(format!("This view needs a newer version of OpenIDEA ({}).", clip(e.kind, 64)))
|
||||
}
|
||||
wit_vt::Body::Other(e) => match e.kind.as_str() {
|
||||
"table" => match sortable_table_from_json(&e.data) {
|
||||
Some(t) => ViewBody::Table(t),
|
||||
None => ViewBody::Message("This view needs a newer version of OpenIDEA (table).".into()),
|
||||
},
|
||||
_ => ViewBody::Message(format!("This view needs a newer version of OpenIDEA ({}).", clip(e.kind, 64))),
|
||||
},
|
||||
wit_vt::Body::Tree(items) => ViewBody::Tree(
|
||||
items
|
||||
.into_iter()
|
||||
@@ -1287,8 +1619,23 @@ fn view_from_wit(v: wit_vt::View) -> View {
|
||||
options: options.into_iter().take(1000).map(|o| clip(o, MAX_TITLE)).collect(),
|
||||
selected: clip(selected, MAX_TITLE),
|
||||
},
|
||||
// A field type of a newer API: left out.
|
||||
wit_vt::FieldValue::Other(_) => return None,
|
||||
wit_vt::FieldValue::Other(e) => match e.kind.as_str() {
|
||||
"label" => FieldValue::Label(clip(serde_json::from_str(&e.data).ok()?, MAX_CELL)),
|
||||
"readonly-text" => {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ReadonlyText {
|
||||
text: String,
|
||||
#[serde(default)]
|
||||
multiline: bool,
|
||||
}
|
||||
let value: ReadonlyText = serde_json::from_str(&e.data).ok()?;
|
||||
FieldValue::ReadonlyText {
|
||||
text: clip(value.text, if value.multiline { 1 << 20 } else { MAX_CELL }),
|
||||
multiline: value.multiline,
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
},
|
||||
};
|
||||
Some(ViewField { key: clip(f.key, 128), label: clip(f.label, MAX_TITLE), value })
|
||||
})
|
||||
@@ -1305,6 +1652,9 @@ fn view_from_wit(v: wit_vt::View) -> View {
|
||||
.map(|r| r.into_iter().take(1000).map(|c| clip(c, MAX_CELL)).collect())
|
||||
.collect(),
|
||||
editable: t.editable,
|
||||
// The plain body predates sortable headers; `other` kind `table` opts in.
|
||||
sortable: false,
|
||||
sort: None,
|
||||
}),
|
||||
wit_vt::Body::Html(h) => ViewBody::Html(clip(h, MAX_HTML)),
|
||||
};
|
||||
@@ -1338,6 +1688,18 @@ fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent {
|
||||
ViewEvent::InsertRow { before } => W::InsertRow(before),
|
||||
ViewEvent::DeleteRows { rows } => W::DeleteRows(rows),
|
||||
ViewEvent::Message { text } => W::Message(text),
|
||||
ViewEvent::TableSelect { row } => {
|
||||
W::Other(wit_types::Extension { kind: "table-select".into(), data: format!(r#"{{"row":{row}}}"#) })
|
||||
}
|
||||
ViewEvent::TableActivate { row } => {
|
||||
W::Other(wit_types::Extension { kind: "table-activate".into(), data: format!(r#"{{"row":{row}}}"#) })
|
||||
}
|
||||
// A sortable table's header click, as an `other` view-event (plugins/
|
||||
// README.md §10): the SDKs decode the kinds they know.
|
||||
ViewEvent::Sort { column, descending } => W::Other(wit_types::Extension {
|
||||
kind: "sort".into(),
|
||||
data: format!(r#"{{"column":{column},"descending":{descending}}}"#),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1345,6 +1707,44 @@ fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn readonly_field_extensions_are_parsed_and_unknown_or_malformed_fields_are_skipped() {
|
||||
let fields = [
|
||||
("label", r#""Title \"quoted\"""#),
|
||||
("readonly-text", r#"{"text":"one\ntwo","multiline":true}"#),
|
||||
("readonly-text", r#"{"text":"author"}"#),
|
||||
("label", "{}"),
|
||||
("readonly-text", r#"{"text":false}"#),
|
||||
("future", "null"),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (kind, data))| wit_vt::Field {
|
||||
key: i.to_string(),
|
||||
label: "Field".into(),
|
||||
value: wit_vt::FieldValue::Other(wit_types::Extension { kind: kind.into(), data: data.into() }),
|
||||
})
|
||||
.collect();
|
||||
let view = view_from_wit(wit_vt::View { toolbar: vec![], body: wit_vt::Body::Form(fields), status: None });
|
||||
let ViewBody::Form(fields) = view.body else { panic!("form") };
|
||||
assert_eq!(fields.len(), 3);
|
||||
assert_eq!(fields[0].value, FieldValue::Label("Title \"quoted\"".into()));
|
||||
assert_eq!(fields[1].value, FieldValue::ReadonlyText { text: "one\ntwo".into(), multiline: true });
|
||||
assert_eq!(fields[2].value, FieldValue::ReadonlyText { text: "author".into(), multiline: false });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_row_events_leave_through_released_extension_case() {
|
||||
for (event, kind) in [
|
||||
(ViewEvent::TableSelect { row: 51 }, "table-select"),
|
||||
(ViewEvent::TableActivate { row: 51 }, "table-activate"),
|
||||
] {
|
||||
let wit_vt::ViewEvent::Other(extension) = event_to_wit_view(event) else { panic!("other") };
|
||||
assert_eq!(extension.kind, kind);
|
||||
assert_eq!(extension.data, r#"{"row":51}"#);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn provider_and_resolved_action_paths_match_ipc_events() {
|
||||
@@ -1360,7 +1760,10 @@ mod tests {
|
||||
kind: None,
|
||||
preferred: false,
|
||||
edits: vec![(path.into(), Vec::new())],
|
||||
operations: Vec::new(),
|
||||
open: None,
|
||||
data: None,
|
||||
file_action: false,
|
||||
};
|
||||
assert_eq!(code_action_to_wit(&action).edits[0].path, expected);
|
||||
}
|
||||
@@ -1383,4 +1786,41 @@ mod tests {
|
||||
let ViewBody::Form(fields) = form.body else { panic!("a form") };
|
||||
assert_eq!(fields.iter().map(|f| f.key.as_str()).collect::<Vec<_>>(), ["name"], "unknown fields are left out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sortable_table_arrives_through_other() {
|
||||
let view = |data: &str| wit_vt::View {
|
||||
toolbar: Vec::new(),
|
||||
body: wit_vt::Body::Other(wit_types::Extension { kind: "table".into(), data: data.into() }),
|
||||
status: None,
|
||||
};
|
||||
let shown = view_from_wit(view(
|
||||
r#"{"columns":["ext","files"],"totalRows":3,"firstRow":1,"rows":[["rs","2"],["md","1"]],
|
||||
"editable":false,"sort":{"column":1,"descending":true}}"#,
|
||||
));
|
||||
let ViewBody::Table(t) = shown.body else { panic!("{shown:?}") };
|
||||
assert_eq!(t.columns, ["ext".to_owned(), "files".into()]);
|
||||
assert_eq!((t.total_rows, t.first_row, t.editable), (3, 1, false));
|
||||
assert!(t.sortable, "a `table` through `other` opts the headers in");
|
||||
assert_eq!(t.sort, Some(TableSort { column: 1, descending: true }));
|
||||
|
||||
// No sort yet: still clickable headers, just no arrow.
|
||||
let fresh = view_from_wit(view(
|
||||
r#"{"columns":["a"],"totalRows":0,"firstRow":0,"rows":[],"editable":false,"sort":null}"#,
|
||||
));
|
||||
let ViewBody::Table(t) = fresh.body else { panic!("{fresh:?}") };
|
||||
assert!(t.sortable);
|
||||
assert_eq!(t.sort, None);
|
||||
|
||||
// Broken JSON degrades like any body the host doesn't know.
|
||||
let broken = view_from_wit(view("{not json"));
|
||||
assert!(matches!(broken.body, ViewBody::Message(_)), "{broken:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sort_click_leaves_as_an_other_extension() {
|
||||
let wit = event_to_wit_view(ViewEvent::Sort { column: 2, descending: true });
|
||||
let wit_vt::ViewEvent::Other(e) = wit else { panic!("{wit:?}") };
|
||||
assert_eq!((e.kind.as_str(), e.data.as_str()), ("sort", r#"{"column":2,"descending":true}"#));
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,53 @@ use std::path::{Path, PathBuf};
|
||||
/// Largest file `read-file` returns.
|
||||
pub const MAX_READ_BYTES: u64 = 16 << 20;
|
||||
|
||||
/// Resolves `path` (absolute, or relative to `root`) and checks that the real
|
||||
/// path — after following `..` and symlinks — stays inside `root`.
|
||||
pub fn resolve_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
/// The canonical `root` and the absolute form of `path` (absolute, or
|
||||
/// relative to `root`) — before any symlink or existence check.
|
||||
fn joined_path(root: &Path, path: &str) -> Result<(PathBuf, PathBuf), String> {
|
||||
if path.is_empty() {
|
||||
return Err("empty path".into());
|
||||
}
|
||||
let root = root.canonicalize().map_err(|e| format!("project root unavailable: {e}"))?;
|
||||
let requested = Path::new(path);
|
||||
let joined = if requested.is_absolute() { requested.to_path_buf() } else { root.join(requested) };
|
||||
Ok((root, joined))
|
||||
}
|
||||
|
||||
/// `joined` after `..`/symlink resolution as far as the file system allows:
|
||||
/// the canonical path when it exists, else the canonicalized deepest existing
|
||||
/// ancestor with the still-missing tail resolved textually — so the location
|
||||
/// of a missing path is still judged where it would really land.
|
||||
pub fn resolve_real(joined: &Path) -> PathBuf {
|
||||
let mut tail = Vec::new();
|
||||
let mut candidate = joined.to_path_buf();
|
||||
let mut real = loop {
|
||||
match candidate.canonicalize() {
|
||||
Ok(real) => break real,
|
||||
Err(_) => {
|
||||
let Some(last) = candidate.components().next_back() else {
|
||||
return joined.to_path_buf();
|
||||
};
|
||||
tail.push(last.as_os_str().to_owned());
|
||||
if !candidate.pop() {
|
||||
return joined.to_path_buf();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
for part in tail.iter().rev() {
|
||||
if part == ".." {
|
||||
real.pop();
|
||||
} else if part != "." {
|
||||
real.push(part);
|
||||
}
|
||||
}
|
||||
real
|
||||
}
|
||||
|
||||
/// Resolves `path` (absolute, or relative to `root`) and checks that the real
|
||||
/// path — after following `..` and symlinks — stays inside `root`.
|
||||
pub fn resolve_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
let (root, joined) = joined_path(root, path)?;
|
||||
let real = joined.canonicalize().map_err(|_| format!("{path}: no such file"))?;
|
||||
if !real.starts_with(&root) {
|
||||
return Err(format!("{path}: access outside the project root is not allowed"));
|
||||
@@ -21,6 +59,58 @@ pub fn resolve_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
Ok(real)
|
||||
}
|
||||
|
||||
/// Whether `path` (absolute, or relative to `root`) lands inside `root`,
|
||||
/// without requiring it to exist: `Ok(true)` also for a missing path, so
|
||||
/// callers distinguish "outside" from "absent". Existing symlinks are
|
||||
/// followed; a missing tail is judged at its would-be location.
|
||||
pub fn inside_root(root: &Path, path: &str) -> Result<bool, String> {
|
||||
let (root, joined) = joined_path(root, path)?;
|
||||
Ok(resolve_real(&joined).starts_with(&root))
|
||||
}
|
||||
|
||||
/// Like [`resolve_in_root`] for a path that may not exist yet (a file about to
|
||||
/// be created) or that names a symlink itself (to delete or rename it): the
|
||||
/// directory part is resolved through symlinks, the final name is kept as is,
|
||||
/// and the result must stay inside `root`.
|
||||
pub fn resolve_new_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
let outside = || format!("{path}: access outside the project root is not allowed");
|
||||
if path.is_empty() {
|
||||
return Err("empty path".into());
|
||||
}
|
||||
let root = root.canonicalize().map_err(|e| format!("project root unavailable: {e}"))?;
|
||||
let requested = Path::new(path);
|
||||
let joined = if requested.is_absolute() { requested.to_path_buf() } else { root.join(requested) };
|
||||
let mut lexical = PathBuf::new();
|
||||
for part in joined.components() {
|
||||
match part {
|
||||
std::path::Component::ParentDir => {
|
||||
lexical.pop();
|
||||
}
|
||||
std::path::Component::CurDir => {}
|
||||
part => lexical.push(part),
|
||||
}
|
||||
}
|
||||
let (Some(name), Some(parent)) = (lexical.file_name(), lexical.parent()) else {
|
||||
return Err(outside());
|
||||
};
|
||||
let mut missing = Vec::new();
|
||||
let mut existing = parent;
|
||||
while !existing.exists() {
|
||||
let (Some(dir), Some(above)) = (existing.file_name(), existing.parent()) else {
|
||||
return Err(outside());
|
||||
};
|
||||
missing.push(dir);
|
||||
existing = above;
|
||||
}
|
||||
let mut real = existing.canonicalize().map_err(|e| format!("{path}: {e}"))?;
|
||||
real.extend(missing.into_iter().rev());
|
||||
real.push(name);
|
||||
if !real.starts_with(&root) {
|
||||
return Err(outside());
|
||||
}
|
||||
Ok(real)
|
||||
}
|
||||
|
||||
/// Reads a UTF-8 file that `resolve_in_root` already approved.
|
||||
pub fn read_text(real: &Path) -> Result<String, String> {
|
||||
let meta = std::fs::metadata(real).map_err(|e| e.to_string())?;
|
||||
@@ -62,6 +152,57 @@ mod tests {
|
||||
assert!(read_text(&resolve_in_root(&root, "src").unwrap()).unwrap_err().contains("not a file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inside_root_covers_missing_paths() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
std::fs::write(root.join("src/a.txt"), "hi").unwrap();
|
||||
std::fs::write(tmp.path().join("secret.txt"), "s").unwrap();
|
||||
|
||||
assert!(inside_root(&root, "src/a.txt").unwrap());
|
||||
assert!(inside_root(&root, "src").unwrap());
|
||||
assert!(inside_root(&root, "missing.txt").unwrap());
|
||||
assert!(inside_root(&root, "missing/deep/nested.txt").unwrap());
|
||||
assert!(inside_root(&root, "missing/../src/a.txt").unwrap());
|
||||
assert!(!inside_root(&root, "../secret.txt").unwrap());
|
||||
assert!(!inside_root(&root, "missing/../../secret.txt").unwrap());
|
||||
assert!(!inside_root(&root, "missing/sub/../../../secret.txt").unwrap());
|
||||
let abs_out = tmp.path().join("secret.txt");
|
||||
assert!(!inside_root(&root, abs_out.to_str().unwrap()).unwrap());
|
||||
assert!(inside_root(&root, "").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_paths_are_confined_to_root() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
let root_real = root.canonicalize().unwrap();
|
||||
|
||||
assert_eq!(resolve_new_in_root(&root, "src/new/a.txt").unwrap(), root_real.join("src/new/a.txt"));
|
||||
assert_eq!(resolve_new_in_root(&root, "src/../b.txt").unwrap(), root_real.join("b.txt"));
|
||||
assert!(resolve_new_in_root(&root, "../x.txt").unwrap_err().contains("outside"));
|
||||
assert!(resolve_new_in_root(&root, "src/../../x.txt").unwrap_err().contains("outside"));
|
||||
assert!(resolve_new_in_root(&root, "/etc/new.txt").is_err());
|
||||
assert!(resolve_new_in_root(&root, "").is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_symlink_is_named_not_followed() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::write(root.join("target.txt"), "t").unwrap();
|
||||
std::os::unix::fs::symlink(root.join("target.txt"), root.join("link.txt")).unwrap();
|
||||
std::os::unix::fs::symlink(tmp.path(), root.join("out")).unwrap();
|
||||
let root_real = root.canonicalize().unwrap();
|
||||
|
||||
assert_eq!(resolve_new_in_root(&root, "link.txt").unwrap(), root_real.join("link.txt"));
|
||||
assert!(resolve_new_in_root(&root, "out/x.txt").unwrap_err().contains("outside"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlinks_cannot_escape() {
|
||||
@@ -71,6 +212,11 @@ mod tests {
|
||||
std::fs::write(tmp.path().join("secret.txt"), "s").unwrap();
|
||||
std::os::unix::fs::symlink(tmp.path().join("secret.txt"), root.join("link")).unwrap();
|
||||
assert!(resolve_in_root(&root, "link").unwrap_err().contains("outside"));
|
||||
assert!(!inside_root(&root, "link").unwrap());
|
||||
assert!(!inside_root(&root, "link/../x").unwrap());
|
||||
std::os::unix::fs::symlink(tmp.path(), root.join("dirlink")).unwrap();
|
||||
assert!(!inside_root(&root, "dirlink/secret.txt").unwrap());
|
||||
assert!(!inside_root(&root, "dirlink/missing.txt").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -77,16 +77,19 @@ pub struct PickItem {
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// What a plugin asks the user (`ui.pick`, `ui.pick-many`, `ui.input`, `ui.confirm`).
|
||||
/// What a plugin asks the user (`ui.pick`, `ui.pick-many`, `ui.input`,
|
||||
/// `ui.input-multiline`, `ui.confirm`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum Prompt {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
QuickPick { title: String, items: Vec<PickItem>, multiple: bool },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Input { prompt: String, value: Option<String> },
|
||||
Input { prompt: String, value: Option<String>, multiline: bool },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Confirm { title: String, message: String },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
PickDirectory { title: String, initial: Option<String> },
|
||||
}
|
||||
|
||||
/// Kinds of workbench events a plugin can subscribe to (`on-event:<kind>`).
|
||||
@@ -210,6 +213,40 @@ pub struct RepositoryStatus {
|
||||
pub changed_files: u32,
|
||||
}
|
||||
|
||||
/// A file a pending commit changes (WIT `commit.commit-change`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommitChange {
|
||||
pub repository: String,
|
||||
pub path: String,
|
||||
pub old_path: Option<String>,
|
||||
/// `added`, `modified`, `deleted`, `renamed`, `copied` or `type-changed`.
|
||||
pub status: String,
|
||||
/// `git diff` of the included changes; `None` for binary content.
|
||||
pub diff: Option<String>,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
/// What Commit would commit (WIT `commit.commit-draft`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommitDraft {
|
||||
pub revision: u64,
|
||||
pub message: String,
|
||||
pub amend: bool,
|
||||
pub changes: Vec<CommitChange>,
|
||||
}
|
||||
|
||||
/// A problem a plugin's before-commit check reported (WIT `commit-checker.commit-problem`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommitProblem {
|
||||
pub message: String,
|
||||
/// Absolute path of the file it is in, if any (IPC form).
|
||||
pub path: Option<String>,
|
||||
pub range: Option<ide_core::Range>,
|
||||
}
|
||||
|
||||
/// Resource limits and supervision policy for plugin code.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Limits {
|
||||
@@ -407,8 +444,21 @@ pub struct CodeAction {
|
||||
pub kind: Option<String>,
|
||||
pub preferred: bool,
|
||||
pub edits: Vec<(PathBuf, Vec<ide_core::TextEdit>)>,
|
||||
/// Applied before `edits`, in one workspace edit with them.
|
||||
pub operations: Vec<FileOperation>,
|
||||
/// Opened once the action is applied.
|
||||
pub open: Option<PathBuf>,
|
||||
/// The plugin's reference: the edits come from `resolve_code_action`.
|
||||
pub data: Option<String>,
|
||||
/// Listed by the `file-actions` export, which also resolves it.
|
||||
pub file_action: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum FileOperation {
|
||||
Create { path: PathBuf, text: String, overwrite: bool },
|
||||
Rename { from: PathBuf, to: PathBuf },
|
||||
Delete { path: PathBuf },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -432,6 +482,16 @@ pub struct PluginHover {
|
||||
pub range: Option<ide_core::Range>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PluginInlayHint {
|
||||
pub position: ide_core::Pos,
|
||||
pub label: String,
|
||||
pub kind: String,
|
||||
pub tooltip: Option<String>,
|
||||
pub padding_left: bool,
|
||||
pub padding_right: bool,
|
||||
}
|
||||
|
||||
// ---- declarative views (wit/views.wit) ---------------------------------------
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
@@ -467,6 +527,8 @@ pub struct ViewListItem {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
|
||||
pub enum FieldValue {
|
||||
Label(String),
|
||||
ReadonlyText { text: String, multiline: bool },
|
||||
Text(String),
|
||||
Multiline(String),
|
||||
Checkbox(bool),
|
||||
@@ -481,6 +543,14 @@ pub struct ViewField {
|
||||
pub value: FieldValue,
|
||||
}
|
||||
|
||||
/// A sortable table's order: the column and the direction.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TableSort {
|
||||
pub column: u32,
|
||||
pub descending: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ViewTable {
|
||||
@@ -489,6 +559,10 @@ pub struct ViewTable {
|
||||
pub first_row: u32,
|
||||
pub rows: Vec<Vec<String>>,
|
||||
pub editable: bool,
|
||||
/// The plugin opted its headers in to click-to-sort (a `table` body through `other`).
|
||||
pub sortable: bool,
|
||||
/// The sort the plugin orders by (none: no arrow yet).
|
||||
pub sort: Option<TableSort>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
@@ -511,7 +585,7 @@ pub struct View {
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
/// A tab of the Services pane of a plugin's node (`service-provider.detail`).
|
||||
/// A tab of the Services pane of a plugin's node (`detail-provider.detail`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServiceTab {
|
||||
@@ -569,4 +643,28 @@ pub enum ViewEvent {
|
||||
InsertRow { before: u32 },
|
||||
DeleteRows { rows: Vec<u32> },
|
||||
Message { text: String },
|
||||
// A sortable table's header click: the column and the direction (leaves
|
||||
// as an `other` view-event, kind `sort`). See `TableSort`.
|
||||
Sort { column: u32, descending: bool },
|
||||
TableSelect { row: u32 },
|
||||
TableActivate { row: u32 },
|
||||
}
|
||||
|
||||
/// A plugin's HTTP route declaration in a project.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Endpoint {
|
||||
pub method: Option<String>,
|
||||
pub path: String,
|
||||
pub framework: String,
|
||||
pub handler: Option<String>,
|
||||
pub location: Location,
|
||||
}
|
||||
|
||||
/// A notification button that runs one of the publishing plugin's commands.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NotificationAction {
|
||||
pub label: String,
|
||||
pub command: String,
|
||||
pub argument: Option<String>,
|
||||
}
|
||||
@@ -42,6 +42,7 @@ pub(crate) mod syntax_bindings {
|
||||
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
|
||||
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
|
||||
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
|
||||
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
|
||||
"openidea:plugin/events": super::bindings::openidea::plugin::events,
|
||||
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
|
||||
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
|
||||
@@ -69,6 +70,7 @@ pub(crate) mod providers_bindings {
|
||||
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
|
||||
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
|
||||
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
|
||||
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
|
||||
"openidea:plugin/events": super::bindings::openidea::plugin::events,
|
||||
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
|
||||
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
|
||||
@@ -96,6 +98,7 @@ pub(crate) mod views_bindings {
|
||||
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
|
||||
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
|
||||
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
|
||||
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
|
||||
"openidea:plugin/events": super::bindings::openidea::plugin::events,
|
||||
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
|
||||
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
|
||||
@@ -109,8 +112,8 @@ pub(crate) mod views_bindings {
|
||||
}
|
||||
|
||||
use bindings::openidea::plugin::{
|
||||
diagnostics, editor, events, invocation, log, net, process, secrets, settings, storage, tasks, types as wit, ui,
|
||||
vcs, views, workspace,
|
||||
commit, diagnostics, editor, events, invocation, log, net, process, secrets, settings, storage, tasks,
|
||||
types as wit, ui, vcs, views, workspace,
|
||||
};
|
||||
|
||||
/// A project handle: its root.
|
||||
@@ -151,6 +154,14 @@ pub(crate) fn to_wit(e: HostError) -> wit::Error {
|
||||
}
|
||||
}
|
||||
|
||||
fn notification_level(level: wit::NotificationLevel) -> NotificationLevel {
|
||||
match level {
|
||||
wit::NotificationLevel::Info => NotificationLevel::Info,
|
||||
wit::NotificationLevel::Warning => NotificationLevel::Warning,
|
||||
wit::NotificationLevel::Error => NotificationLevel::Error,
|
||||
}
|
||||
}
|
||||
|
||||
/// A guest's `error` as text (`denied: net:example.com`, `stale`, …).
|
||||
impl From<wit::Error> for crate::runtime::GuestError {
|
||||
fn from(e: wit::Error) -> Self {
|
||||
@@ -263,19 +274,40 @@ impl StoreData {
|
||||
}
|
||||
|
||||
impl ui::Host for StoreData {
|
||||
fn notify(
|
||||
&mut self,
|
||||
level: wit::NotificationLevel,
|
||||
title: String,
|
||||
body: String,
|
||||
actions: Vec<ui::NotificationAction>,
|
||||
) -> Fallible<()> {
|
||||
const MAX_ACTIONS: usize = 3;
|
||||
if actions.len() > MAX_ACTIONS {
|
||||
return Ok(Err(wit::Error::Invalid(format!("a notification has at most {MAX_ACTIONS} actions"))));
|
||||
}
|
||||
let mut checked = Vec::with_capacity(actions.len());
|
||||
for a in actions {
|
||||
if a.label.trim().is_empty() || a.label.len() > 256 || a.label.chars().any(char::is_control) {
|
||||
return Ok(Err(wit::Error::Invalid(format!("invalid action label {:?}", a.label))));
|
||||
}
|
||||
if a.argument.as_ref().is_some_and(|arg| arg.len() > 64 << 10) {
|
||||
return Ok(Err(wit::Error::Invalid("an action argument is limited to 64 KiB".into())));
|
||||
}
|
||||
checked.push(crate::NotificationAction { label: a.label, command: a.command, argument: a.argument });
|
||||
}
|
||||
let (title, body) = (clip(title, MAX_TITLE), clip(body, 16 << 10));
|
||||
Ok(self.host.notify(self.client(), notification_level(level), title, body, checked).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn show_notification(
|
||||
&mut self,
|
||||
level: wit::NotificationLevel,
|
||||
title: String,
|
||||
body: String,
|
||||
) -> wasmtime::Result<()> {
|
||||
let level = match level {
|
||||
wit::NotificationLevel::Info => NotificationLevel::Info,
|
||||
wit::NotificationLevel::Warning => NotificationLevel::Warning,
|
||||
wit::NotificationLevel::Error => NotificationLevel::Error,
|
||||
};
|
||||
let max = crate::runtime::MAX_TITLE;
|
||||
self.host.notify(self.client(), level, clip(title, max), clip(body, 16 << 10));
|
||||
let (title, body) = (clip(title, MAX_TITLE), clip(body, 16 << 10));
|
||||
// A plain notification has nothing the host could refuse.
|
||||
let _ = self.host.notify(self.client(), notification_level(level), title, body, Vec::new());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -347,12 +379,35 @@ impl ui::Host for StoreData {
|
||||
Ok(answer.map(|keys| keys.and_then(|keys| keys.into_iter().next())))
|
||||
}
|
||||
|
||||
fn pick_directory(&mut self, title: String, initial: Option<String>) -> Fallible<Option<String>> {
|
||||
if let Some(path) = &initial
|
||||
&& let Err(error) = validate_ui_path(path)
|
||||
{
|
||||
return Ok(Err(error));
|
||||
}
|
||||
let answer = self.ask(Prompt::PickDirectory { title: clip(title, MAX_TITLE), initial });
|
||||
Ok(answer.and_then(|answer| {
|
||||
let path = answer.and_then(|items| items.into_iter().next());
|
||||
if let Some(path) = &path {
|
||||
validate_ui_path(path)?;
|
||||
}
|
||||
Ok(path)
|
||||
}))
|
||||
}
|
||||
|
||||
fn pick_many(&mut self, title: String, items: Vec<ui::PickItem>) -> Fallible<Option<Vec<String>>> {
|
||||
Ok(self.pick_prompt(title, items, true))
|
||||
}
|
||||
|
||||
fn input(&mut self, prompt: String, value: Option<String>) -> Fallible<Option<String>> {
|
||||
let prompt = Prompt::Input { prompt: clip(prompt, MAX_TITLE), value: value.map(|v| clip(v, 4096)) };
|
||||
let prompt =
|
||||
Prompt::Input { prompt: clip(prompt, MAX_TITLE), value: value.map(|v| clip(v, 4096)), multiline: false };
|
||||
Ok(self.ask(prompt).map(|answer| answer.and_then(|text| text.into_iter().next())))
|
||||
}
|
||||
|
||||
fn input_multiline(&mut self, prompt: String, value: Option<String>) -> Fallible<Option<String>> {
|
||||
let prompt =
|
||||
Prompt::Input { prompt: clip(prompt, MAX_TITLE), value: value.map(|v| clip(v, 4096)), multiline: true };
|
||||
Ok(self.ask(prompt).map(|answer| answer.and_then(|text| text.into_iter().next())))
|
||||
}
|
||||
|
||||
@@ -360,6 +415,10 @@ impl ui::Host for StoreData {
|
||||
let prompt = Prompt::Confirm { title: clip(title, MAX_TITLE), message: clip(body, 16 << 10) };
|
||||
Ok(self.ask(prompt).map(|answer| answer.is_some()))
|
||||
}
|
||||
|
||||
fn locale(&mut self) -> wasmtime::Result<String> {
|
||||
Ok(self.host.locale())
|
||||
}
|
||||
}
|
||||
|
||||
impl storage::Host for StoreData {
|
||||
@@ -393,6 +452,18 @@ impl storage::Host for StoreData {
|
||||
}
|
||||
|
||||
impl workspace::Host for StoreData {
|
||||
fn open_project(&mut self, path: String, new_window: bool) -> Fallible<()> {
|
||||
if let Err(error) = self.require_interactive().and_then(|_| validate_ui_path(&path)) {
|
||||
return Ok(Err(error));
|
||||
}
|
||||
let client = self.invocation.client.clone();
|
||||
Ok(self.host.open_project(client.as_deref(), path, new_window).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn project_of(&mut self, path: String) -> wasmtime::Result<Option<Resource<ProjectRes>>> {
|
||||
self.host.project_of(Path::new(&path)).map(|root| self.push(ProjectRes { root })).transpose()
|
||||
}
|
||||
|
||||
fn projects(&mut self) -> Fallible<Vec<Resource<ProjectRes>>> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
@@ -421,7 +492,10 @@ impl workspace::HostProject for StoreData {
|
||||
let root = self.table.get(&this)?.root.clone();
|
||||
let real = match sandbox::resolve_in_root(&root, &path) {
|
||||
Ok(real) => real,
|
||||
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
|
||||
Err(e) => {
|
||||
let e = if e.ends_with("no such file") { wit::Error::NotFound(e) } else { wit::Error::Invalid(e) };
|
||||
return Ok(Err(e));
|
||||
}
|
||||
};
|
||||
if let Some(text) = self.host.buffer_text(&real) {
|
||||
return Ok(Ok(text));
|
||||
@@ -429,6 +503,57 @@ impl workspace::HostProject for StoreData {
|
||||
Ok(sandbox::read_text(&real).map_err(wit::Error::Failed))
|
||||
}
|
||||
|
||||
fn exists(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<bool> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let root = self.table.get(&this)?.root.clone();
|
||||
match sandbox::inside_root(&root, &path) {
|
||||
Err(e) => Ok(Err(wit::Error::Invalid(e))),
|
||||
Ok(false) => {
|
||||
Ok(Err(wit::Error::Invalid(format!("{path}: access outside the project root is not allowed"))))
|
||||
}
|
||||
Ok(true) => {
|
||||
let requested = Path::new(&path);
|
||||
let joined = if requested.is_absolute() { requested.to_path_buf() } else { root.join(requested) };
|
||||
Ok(Ok(joined.exists()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stat(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<workspace::FileStat> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let root = self.table.get(&this)?.root.clone();
|
||||
let real = match sandbox::resolve_in_root(&root, &path) {
|
||||
Ok(real) => real,
|
||||
Err(e) => {
|
||||
let e = if e.ends_with("no such file") { wit::Error::NotFound(e) } else { wit::Error::Invalid(e) };
|
||||
return Ok(Err(e));
|
||||
}
|
||||
};
|
||||
let meta = match std::fs::symlink_metadata(&real) {
|
||||
Ok(m) => m,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(Err(wit::Error::NotFound(format!("{path}: no such file"))));
|
||||
}
|
||||
Err(e) => return Ok(Err(wit::Error::Failed(e.to_string()))),
|
||||
};
|
||||
let kind = if meta.is_file() {
|
||||
workspace::FileKind::File
|
||||
} else if meta.is_dir() {
|
||||
workspace::FileKind::Directory
|
||||
} else if meta.is_symlink() {
|
||||
workspace::FileKind::Symlink
|
||||
} else {
|
||||
workspace::FileKind::Other
|
||||
};
|
||||
let modified =
|
||||
meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs());
|
||||
Ok(Ok(workspace::FileStat { kind, size: meta.len(), modified }))
|
||||
}
|
||||
|
||||
fn find_files(&mut self, this: Resource<ProjectRes>, glob: String, limit: u32) -> Fallible<Vec<String>> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
@@ -571,6 +696,62 @@ impl vcs::Host for StoreData {
|
||||
}
|
||||
}
|
||||
|
||||
impl commit::Host for StoreData {
|
||||
fn pending_commit(&mut self) -> Fallible<Option<commit::CommitDraft>> {
|
||||
if let Err(e) = self.require(Capability::VcsRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if let Err(e) = self.require_interactive() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
// Waiting for the window (saving, diffing) doesn't count against the call's budget.
|
||||
let client = self.invocation.client.clone();
|
||||
let started = Instant::now();
|
||||
let draft = self.host.pending_commit(client.as_deref(), &self.cancel.token());
|
||||
let waited = started.elapsed();
|
||||
self.deadline += waited;
|
||||
self.call_started += waited;
|
||||
Ok(draft.map(|d| d.map(commit_draft_to_wit)).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn set_commit_message(&mut self, revision: u64, message: String) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::VcsWrite) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if let Err(e) = self.require_interactive() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if message.len() > MAX_COMMIT_MESSAGE {
|
||||
return Ok(Err(wit::Error::Invalid(format!("a commit message is at most {MAX_COMMIT_MESSAGE} bytes"))));
|
||||
}
|
||||
let client = self.invocation.client.clone();
|
||||
Ok(self.host.set_commit_message(client.as_deref(), revision, message).map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest commit message a plugin may set.
|
||||
const MAX_COMMIT_MESSAGE: usize = 64 << 10;
|
||||
|
||||
pub(crate) fn commit_draft_to_wit(d: crate::types::CommitDraft) -> commit::CommitDraft {
|
||||
commit::CommitDraft {
|
||||
revision: d.revision,
|
||||
message: d.message,
|
||||
amend: d.amend,
|
||||
changes: d
|
||||
.changes
|
||||
.into_iter()
|
||||
.map(|c| commit::CommitChange {
|
||||
repository: c.repository,
|
||||
path: c.path,
|
||||
old_path: c.old_path,
|
||||
status: c.status,
|
||||
diff: c.diff,
|
||||
truncated: c.truncated,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
impl events::Host for StoreData {
|
||||
fn subscribe(&mut self, kind: String) -> Fallible<()> {
|
||||
let Some(kind) = EventKind::parse(&kind) else {
|
||||
@@ -581,14 +762,19 @@ impl events::Host for StoreData {
|
||||
{
|
||||
return Ok(Err(e));
|
||||
}
|
||||
self.host.subscribe(kind, true);
|
||||
self.host.subscribe(kind);
|
||||
Ok(Ok(()))
|
||||
}
|
||||
|
||||
fn unsubscribe(&mut self, kind: String) -> wasmtime::Result<()> {
|
||||
if let Some(kind) = EventKind::parse(&kind) {
|
||||
self.host.subscribe(kind, false);
|
||||
fn subscribe_paths(&mut self, kind: String, globs: Vec<String>) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::Events) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
Ok(self.host.subscribe_paths(&kind, globs).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn unsubscribe(&mut self, kind: String) -> wasmtime::Result<()> {
|
||||
self.host.unsubscribe(&kind);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -815,10 +1001,10 @@ pub(crate) mod indexing_bindings {
|
||||
});
|
||||
}
|
||||
|
||||
/// The optional `service-provider` export.
|
||||
pub(crate) mod services_bindings {
|
||||
/// The optional `detail-provider` export.
|
||||
pub(crate) mod details_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
world: "services", path: "../../wit",
|
||||
world: "details", path: "../../wit",
|
||||
with: {
|
||||
"openidea:plugin/types": super::bindings::openidea::plugin::types,
|
||||
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
|
||||
@@ -828,6 +1014,20 @@ pub(crate) mod services_bindings {
|
||||
});
|
||||
}
|
||||
|
||||
/// The optional `commit-checker` export.
|
||||
pub(crate) mod commit_checks_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
world: "commit-checks", path: "../../wit",
|
||||
with: {
|
||||
"openidea:plugin/types": super::bindings::openidea::plugin::types,
|
||||
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
|
||||
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
|
||||
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
|
||||
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) mod semantic_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
world: "semantic-tokens", path: "../../wit",
|
||||
@@ -841,3 +1041,59 @@ pub(crate) mod hover_bindings {
|
||||
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) mod inlay_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
world: "inlay-hints", path: "../../wit",
|
||||
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) mod endpoints_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
world: "endpoints", path: "../../wit",
|
||||
with: {
|
||||
"openidea:plugin/types": super::bindings::openidea::plugin::types,
|
||||
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
|
||||
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
|
||||
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/// The optional `file-actions` export.
|
||||
pub(crate) mod file_actions_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
world: "file-actions-provider", path: "../../wit",
|
||||
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
|
||||
});
|
||||
}
|
||||
|
||||
/// Paths belong to the UI machine, which can have a different OS than the plugin host.
|
||||
fn validate_ui_path(path: &str) -> Result<(), wit::Error> {
|
||||
let b = path.as_bytes();
|
||||
let absolute = path.starts_with('/')
|
||||
|| path.starts_with("\\\\")
|
||||
|| (b.first().is_some_and(u8::is_ascii_alphabetic)
|
||||
&& b.get(1) == Some(&b':')
|
||||
&& matches!(b.get(2), Some(b'/' | b'\\')));
|
||||
if !absolute || path.len() > 8192 || path.chars().any(char::is_control) {
|
||||
return Err(wit::Error::Invalid("expected an absolute UI directory path of at most 8192 bytes".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod project_ui_tests {
|
||||
use super::validate_ui_path;
|
||||
#[test]
|
||||
fn validates_ui_paths_without_using_the_plugin_hosts_os() {
|
||||
for path in ["/work/repo", "C:/work/repo", r"C:\work\repo", r"\\server\share\repo"] {
|
||||
assert!(validate_ui_path(path).is_ok(), "{path:?}");
|
||||
}
|
||||
for path in ["repo", "C:repo", "/bad\npath", ""] {
|
||||
assert!(validate_ui_path(path).is_err(), "{path:?}");
|
||||
}
|
||||
assert!(validate_ui_path(&format!("/{}", "x".repeat(8192))).is_err());
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ fn components() -> Vec<PathBuf> {
|
||||
repo().join("crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm"),
|
||||
repo().join("crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm"),
|
||||
repo().join("crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm"),
|
||||
repo().join("crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm"),
|
||||
];
|
||||
let official = repo().join("plugins/official");
|
||||
let mut dirs: Vec<_> = std::fs::read_dir(&official).unwrap().map(|e| e.unwrap().path()).collect();
|
||||
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-test-commit"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"macro-string",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-ident",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "openidea-test-commit"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
@@ -0,0 +1,9 @@
|
||||
# Commit test fixture
|
||||
|
||||
`Describe Changes` (in the `commit.message` toolbar) reads the commit being
|
||||
prepared and writes a message listing its files; the `markers` before-commit
|
||||
check reports a message without a `type: ` prefix and every `DO NOT COMMIT`
|
||||
added by the commit.
|
||||
|
||||
Used by `cargo test -p ide-host --test plugin_commit` to exercise the `commit`
|
||||
interface and the optional `commit-checker` export. It is not a marketplace plugin.
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
73c4c46303636f64afe278feb3987937ad64be427783412ba8a5f67e966b9b3b
|
||||
@@ -0,0 +1,21 @@
|
||||
id = "test.commit"
|
||||
name = "Commit fixture"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "^1.3"
|
||||
wasm = "dist/plugin.wasm"
|
||||
activation = ["on-command:*"]
|
||||
|
||||
[capabilities]
|
||||
required = ["vcs.read", "vcs.write"]
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "describe"
|
||||
title = "Commit Fixture: Describe Changes"
|
||||
|
||||
[[contributes.menus]]
|
||||
menu = "commit.message"
|
||||
command = "describe"
|
||||
|
||||
[[contributes.commitChecks]]
|
||||
id = "markers"
|
||||
title = "Commit Markers"
|
||||
@@ -0,0 +1,57 @@
|
||||
use openidea_plugin::commit::{CommitChecks, CommitProblem};
|
||||
use openidea_plugin::{CommitDraft, Error, Invocation, Location, Plugin, Position, Range, host, register_plugin};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Fixture;
|
||||
|
||||
impl Plugin for Fixture {
|
||||
fn run_command(&mut self, id: &str, _: &Invocation, _: Option<&str>) -> Result<Option<String>, Error> {
|
||||
if id != "describe" {
|
||||
return Err(Error::NotFound(id.into()));
|
||||
}
|
||||
let draft = host::pending_commit()?.ok_or("no repository")?;
|
||||
let names: Vec<String> = draft
|
||||
.changes
|
||||
.iter()
|
||||
.map(|c| format!("{} {}", c.status, c.path.rsplit('/').next().unwrap_or(&c.path)))
|
||||
.collect();
|
||||
let message = format!("chore: {}", names.join(", "));
|
||||
host::set_commit_message(draft.revision, &message)?;
|
||||
Ok(Some(message))
|
||||
}
|
||||
}
|
||||
|
||||
impl CommitChecks for Fixture {
|
||||
fn check(&mut self, check: &str, draft: &CommitDraft, _: &Invocation) -> Result<Vec<CommitProblem>, Error> {
|
||||
if check != "markers" {
|
||||
return Err(Error::NotFound(check.into()));
|
||||
}
|
||||
let mut problems = Vec::new();
|
||||
if !draft.message.split_once(": ").is_some_and(|(t, _)| !t.is_empty() && !t.contains(' ')) {
|
||||
problems.push(CommitProblem::new("The message does not start with `type: `."));
|
||||
}
|
||||
for change in &draft.changes {
|
||||
let Some(diff) = &change.diff else { continue };
|
||||
let mut line = 0u32;
|
||||
for l in diff.lines() {
|
||||
if let Some(hunk) = l.strip_prefix("@@ -") {
|
||||
// `@@ -a,b +c,d @@`: c is the 1-based first line of the new side.
|
||||
let new = hunk.split(" +").nth(1).and_then(|s| s.split([',', ' ']).next());
|
||||
line = new.and_then(|n| n.parse::<u32>().ok()).unwrap_or(1).saturating_sub(1);
|
||||
} else if l.starts_with('+') && !l.starts_with("+++") {
|
||||
if l.contains("DO NOT COMMIT") {
|
||||
let at = Position::new(line, 0);
|
||||
let location = Location { path: change.path.clone(), range: Range::new(at, at) };
|
||||
problems.push(CommitProblem::new("Remove the DO NOT COMMIT marker.").at(location));
|
||||
}
|
||||
line += 1;
|
||||
} else if !l.starts_with('-') {
|
||||
line += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(problems)
|
||||
}
|
||||
}
|
||||
|
||||
register_plugin!(Fixture, commit_checks);
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-test-endpoints"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"macro-string",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-ident",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "openidea-test-endpoints"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
4f66df6e4650759aebcf0a1b75550bbad09acf3ac50129f55c17edf5a35ebc90
|
||||
@@ -0,0 +1,13 @@
|
||||
id = "test.endpoints"
|
||||
name = "Endpoints fixture"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "^1.3"
|
||||
wasm = "dist/plugin.wasm"
|
||||
capabilities.required = ["project.read"]
|
||||
|
||||
[[contributes.endpoints]]
|
||||
files = ["routes.txt"]
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "crash"
|
||||
title = "Crash endpoint fixture"
|
||||
@@ -0,0 +1,32 @@
|
||||
use openidea_plugin::endpoints::{Endpoint, EndpointsProvider};
|
||||
use openidea_plugin::{Error, Invocation, Location, Plugin, Position, Range, register_plugin};
|
||||
#[derive(Default)]
|
||||
struct Fixture;
|
||||
impl Plugin for Fixture {
|
||||
fn run_command(&mut self, id: &str, _: &Invocation, _: Option<&str>) -> Result<Option<String>, Error> {
|
||||
if id == "crash" {
|
||||
panic!("endpoint fixture crash");
|
||||
}
|
||||
Err(Error::NotFound(id.into()))
|
||||
}
|
||||
}
|
||||
impl EndpointsProvider for Fixture {
|
||||
fn endpoints(&mut self, ctx: &Invocation) -> Result<Vec<Endpoint>, Error> {
|
||||
let project = ctx.project().ok_or("no project invocation")?;
|
||||
let text = project.read_file("routes.txt")?;
|
||||
if text.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
Ok(vec![Endpoint {
|
||||
method: None,
|
||||
path: text.lines().next().unwrap().into(),
|
||||
framework: "Fixture".into(),
|
||||
handler: Some("Routes#show".into()),
|
||||
location: Location {
|
||||
path: format!("{}/routes.txt", project.root()?),
|
||||
range: Range { start: Position::new(0, 0), end: Position::new(0, 0) },
|
||||
},
|
||||
}])
|
||||
}
|
||||
}
|
||||
register_plugin!(Fixture, endpoints);
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
885badd4727639ad3205b2551d2b8406490fc05b72c684c5d17f09aadd9541bd
|
||||
83081a6195792336c98704cd270ef8373fd25e73d14a86b517f12eafe845dee1
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
//! Sample OpenIDEA plugin, written with the `openidea-plugin` SDK.
|
||||
//! Build with `openidea-plugin build crates/ide-plugin/tests/fixtures/hello` (see plugins/README.md).
|
||||
|
||||
use openidea_plugin::services::{Pane, ProcessLog, Services, Tab};
|
||||
use openidea_plugin::details::{Details, Pane, ProcessLog, Tab};
|
||||
use openidea_plugin::views::{Body, Button, TreeItem, View, ViewEvent, Viewport, Views};
|
||||
use openidea_plugin::{Context, Error, Invocation, LogLevel, NotificationLevel, Plugin, host, register_plugin};
|
||||
use std::collections::BTreeSet;
|
||||
@@ -98,14 +98,14 @@ impl Views for Hello {
|
||||
}
|
||||
|
||||
/// As a services view (tests): a node's key is the `sh -c` script its Log tab runs.
|
||||
impl Services for Hello {
|
||||
impl Details for Hello {
|
||||
fn detail(&mut self, view: &str, key: &str, _ctx: &Invocation) -> Result<Vec<Tab>, Error> {
|
||||
let log = ProcessLog::new("sh", &["-c", key]).toggle("named", "Named", &["named"]);
|
||||
Ok(vec![Tab::new("log", "Log", log), Tab::new("info", "Info", Pane::Text(format!("{view}: {key}")))])
|
||||
}
|
||||
}
|
||||
|
||||
register_plugin!(Hello, views, services);
|
||||
register_plugin!(Hello, views, details);
|
||||
|
||||
/// Today's UTC date as `YYYY-MM-DD` (WASI wall clock).
|
||||
fn today() -> String {
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
ff1b39ac7850fbe4495e274dd78cca4c906a20705e8b9a975f58cf564e8ff137
|
||||
bb94b980261fc996c92bc8eceb5a89e8dbdaf26b5e4466223a86fe8abb9b63e0
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
c9bb4bc3603d9e7c770cf13ad413f5b3a787e78dbeed8f41a3b8537c0740275d
|
||||
1e94c7de6c49e8b821b4e60825385bef5954983e983530d91b2d765eb9dda9ef
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-test-inlay-hints"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"macro-string",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-ident",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "openidea-test-inlay-hints"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
@@ -0,0 +1,7 @@
|
||||
# Inlay hints test fixture
|
||||
|
||||
After every `->name()` call in a PHP document that declares
|
||||
`function name(): Type`, shows `: Type` as a type hint.
|
||||
|
||||
Used by `cargo test -p ide-host --test plugin_inlay_hints` to exercise the
|
||||
optional `inlay-hints-provider` export. It is not a marketplace plugin.
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
17137e31b7997f20beee8ddc1c30918428c4083cb7284ad505a4b888b1ba34c0
|
||||
@@ -0,0 +1,9 @@
|
||||
id = "test.inlay-hints"
|
||||
name = "Inlay hints fixture"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "^1.3"
|
||||
wasm = "dist/plugin.wasm"
|
||||
|
||||
[[contributes.providers]]
|
||||
kinds = ["inlay-hints"]
|
||||
languages = ["php"]
|
||||
@@ -0,0 +1,47 @@
|
||||
use openidea_plugin::inlay_hints::{Document, InlayHint, InlayHints, InlayKind};
|
||||
use openidea_plugin::{Error, Invocation, Plugin, Range, register_plugin, text};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Fixture;
|
||||
|
||||
impl Plugin for Fixture {
|
||||
fn run_command(&mut self, id: &str, _: &Invocation, _: Option<&str>) -> Result<Option<String>, Error> {
|
||||
Err(Error::NotFound(id.into()))
|
||||
}
|
||||
}
|
||||
|
||||
impl InlayHints for Fixture {
|
||||
fn inlay_hints(&mut self, doc: &Document, _range: Range) -> Vec<InlayHint> {
|
||||
let text = doc.text.as_str();
|
||||
let mut out = Vec::new();
|
||||
let mut rest = 0;
|
||||
while let Some(i) = text[rest..].find("->") {
|
||||
let start = rest + i + 2;
|
||||
let end = text[start..]
|
||||
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
|
||||
.map_or(text.len(), |n| start + n);
|
||||
rest = end;
|
||||
if end == start || !text[end..].starts_with("()") {
|
||||
continue;
|
||||
}
|
||||
let name = &text[start..end];
|
||||
let declared = format!("function {name}(): ");
|
||||
let Some(at) = text.find(&declared) else { continue };
|
||||
let ty: String = text[at + declared.len()..]
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '\\' | '?'))
|
||||
.collect();
|
||||
if ty.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.push(
|
||||
InlayHint::new(text::position(text, end + 2), format!(": {ty}"))
|
||||
.kind(InlayKind::Type)
|
||||
.tooltip(format!("{name}() returns {ty}")),
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
register_plugin!(Fixture, inlay_hints);
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
a9cb7302b86e12e05cbbf87fb402609e2f88d3a463f6c6e96b254e643b76183d
|
||||
85f4f7fed98833c0a70346f37279c0e27bac5f07cfeb50c4b7afab290a1bd87e
|
||||
@@ -38,3 +38,18 @@ category = "Jobs"
|
||||
[contributes.settings]
|
||||
title = "Jobs"
|
||||
schema = "settings.schema.json"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "notify-actions"
|
||||
title = "Jobs: Show Notification Actions"
|
||||
category = "Jobs"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "pick-directory"
|
||||
title = "Pick directory"
|
||||
[[contributes.commands]]
|
||||
id = "open-project"
|
||||
title = "Open project"
|
||||
[[contributes.commands]]
|
||||
id = "open-project-new"
|
||||
title = "Open project in new window"
|
||||
@@ -53,6 +53,23 @@ impl Plugin for Jobs {
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
"notify-actions" => {
|
||||
if arg == Some("plain") {
|
||||
host::notify(NotificationLevel::Info, "Choose an action", "A plugin notification");
|
||||
return Ok(None);
|
||||
}
|
||||
use openidea_plugin::NotificationAction;
|
||||
let command = if arg == Some("unknown") { "not-declared" } else { "context" };
|
||||
let count = if arg == Some("four") { 4 } else { 3 };
|
||||
let actions = (0..count)
|
||||
.map(|i| {
|
||||
NotificationAction::new(format!("Action {}", i + 1), command)
|
||||
.argument(format!("notification-action={}\nvalue", i + 1))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
host::notify_with_actions(NotificationLevel::Info, "Choose an action", "A plugin notification", &actions)?;
|
||||
Ok(None)
|
||||
}
|
||||
"copy" => {
|
||||
host::copy_to_clipboard(arg.unwrap_or_default())?;
|
||||
Ok(None)
|
||||
@@ -61,6 +78,11 @@ impl Plugin for Jobs {
|
||||
host::open_url(arg.unwrap_or_default())?;
|
||||
Ok(None)
|
||||
}
|
||||
"pick-directory" => Ok(Some(host::pick_directory("Clone target", arg)?.unwrap_or_else(|| "none".into()))),
|
||||
"open-project" | "open-project-new" => {
|
||||
host::open_project(arg.unwrap_or_default(), id == "open-project-new")?;
|
||||
Ok(None)
|
||||
}
|
||||
"pick" | "pick-many" => {
|
||||
let long_key = "k".repeat(300);
|
||||
let items = [
|
||||
@@ -75,6 +97,7 @@ impl Plugin for Jobs {
|
||||
Ok(Some(picked.map_or("none".to_owned(), |keys| keys.join(","))))
|
||||
}
|
||||
"input" => Ok(Some(host::input("Name", arg)?.unwrap_or_else(|| "none".to_owned()))),
|
||||
"input-multiline" => Ok(Some(host::input_multiline("Name", arg)?.unwrap_or_else(|| "none".to_owned()))),
|
||||
"confirm" => Ok(Some(host::confirm("Delete", "Really?")?.to_string())),
|
||||
"reveal" => host::reveal_view(arg.unwrap_or_default()).map(|()| None),
|
||||
"selection" => {
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
0be22488fe8f20a59f48fc61089b3db791a5d723d71e4d654bfd008038c01003
|
||||
ca8e84a495a450c61c818e6807a71463ae17716a06ece993849239a24f08e2e6
|
||||
@@ -7,6 +7,7 @@ wit_bindgen::generate!({
|
||||
world misbehave {
|
||||
include openidea:plugin/plugin-syntax@1.3.0;
|
||||
export openidea:plugin/providers@1.3.0;
|
||||
export openidea:plugin/file-actions@1.3.0;
|
||||
}
|
||||
",
|
||||
path: "../../../../../wit",
|
||||
@@ -189,4 +190,64 @@ impl providers::Guest for Misbehave {
|
||||
}
|
||||
}
|
||||
|
||||
/// `files`: one action that creates, renames and deletes files, then opens
|
||||
/// one, and one listed with `data` only, whose file `resolve` creates.
|
||||
impl exports::openidea::plugin::file_actions::Guest for Misbehave {
|
||||
fn code_actions(
|
||||
doc: exports::openidea::plugin::file_actions::Document,
|
||||
_selection: types::Range,
|
||||
) -> Vec<exports::openidea::plugin::file_actions::FileAction> {
|
||||
use exports::openidea::plugin::file_actions::{CreateFile, DeleteFile, FileAction, FileOperation, RenameFile};
|
||||
if doc.text != "files" {
|
||||
return Vec::new();
|
||||
}
|
||||
let dir = doc.path.rsplit_once('/').map_or("", |(dir, _)| dir).to_owned();
|
||||
vec![
|
||||
FileAction {
|
||||
title: "Reorganise".into(),
|
||||
kind: Some("quickfix".into()),
|
||||
preferred: false,
|
||||
operations: vec![
|
||||
FileOperation::Create(CreateFile {
|
||||
path: format!("{dir}/created.txt"),
|
||||
text: "hello".into(),
|
||||
overwrite: false,
|
||||
}),
|
||||
FileOperation::Rename(RenameFile {
|
||||
path: format!("{dir}/old.txt"),
|
||||
new_path: format!("{dir}/renamed.txt"),
|
||||
}),
|
||||
FileOperation::Delete(DeleteFile { path: format!("{dir}/gone.txt") }),
|
||||
],
|
||||
edits: Vec::new(),
|
||||
open: Some(format!("{dir}/created.txt")),
|
||||
data: None,
|
||||
},
|
||||
FileAction {
|
||||
title: "Create later".into(),
|
||||
kind: Some("quickfix".into()),
|
||||
preferred: false,
|
||||
operations: Vec::new(),
|
||||
edits: Vec::new(),
|
||||
open: None,
|
||||
data: Some(dir),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn resolve(
|
||||
_doc: exports::openidea::plugin::file_actions::Document,
|
||||
action: exports::openidea::plugin::file_actions::FileAction,
|
||||
) -> Result<exports::openidea::plugin::file_actions::FileAction, types::Error> {
|
||||
use exports::openidea::plugin::file_actions::{CreateFile, FileOperation};
|
||||
let dir = action.data.clone().ok_or_else(|| types::Error::Invalid("no data".into()))?;
|
||||
let operations = vec![FileOperation::Create(CreateFile {
|
||||
path: format!("{dir}/later.txt"),
|
||||
text: "later".into(),
|
||||
overwrite: false,
|
||||
})];
|
||||
Ok(exports::openidea::plugin::file_actions::FileAction { operations, data: None, ..action })
|
||||
}
|
||||
}
|
||||
|
||||
export!(Misbehave);
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-test-path-events"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"macro-string",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-ident",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "openidea-test-path-events"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
66d958477027d1fc085f4096d082ddb71a26e173c4a93cfda42d3e770f27b25d
|
||||
@@ -0,0 +1,34 @@
|
||||
id = "test.path-events"
|
||||
name = "Path event fixture"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "^1.3"
|
||||
wasm = "dist/plugin.wasm"
|
||||
activation = ["on-command:*"]
|
||||
|
||||
[capabilities]
|
||||
required = ["storage"]
|
||||
optional = ["events", "project.read"]
|
||||
|
||||
[[contributes.providers]]
|
||||
kinds = ["hover"]
|
||||
languages = ["text", "plaintext"]
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "project"
|
||||
title = "project"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "filter"
|
||||
title = "filter"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "all"
|
||||
title = "all"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "off"
|
||||
title = "off"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "count"
|
||||
title = "count"
|
||||
@@ -0,0 +1,47 @@
|
||||
use openidea_plugin::hover::{Document, Hover, HoverProvider};
|
||||
use openidea_plugin::{Context, Error, Event, EventKind, Invocation, Plugin, Position, host, register_plugin};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Fixture;
|
||||
|
||||
impl Plugin for Fixture {
|
||||
fn activate(&mut self, _: &Context) -> Result<(), Error> {
|
||||
// Persist the chosen filter so reactivation does not overwrite it.
|
||||
let glob = host::storage::get("filter")?.unwrap_or_else(|| "config/*.yaml".into());
|
||||
let globs: Vec<_> = glob.lines().collect();
|
||||
let _ = host::subscribe_paths("file-opened", &globs);
|
||||
let _ = host::subscribe_paths("file-saved", &["config/*.yaml"]);
|
||||
Ok(())
|
||||
}
|
||||
fn run_command(&mut self, id: &str, _: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> {
|
||||
match id {
|
||||
"project" => host::project_of(arg.unwrap_or_default()).map(|p| p.root()).transpose(),
|
||||
"filter" => {
|
||||
let glob = arg.unwrap_or_default();
|
||||
host::subscribe_paths("file-opened", &glob.lines().collect::<Vec<_>>())?;
|
||||
host::storage::set("filter", glob)?;
|
||||
Ok(None)
|
||||
}
|
||||
"all" => {
|
||||
host::subscribe(EventKind::FileOpened)?;
|
||||
Ok(None)
|
||||
}
|
||||
"off" => {
|
||||
host::unsubscribe(EventKind::FileOpened);
|
||||
Ok(None)
|
||||
}
|
||||
"count" => Ok(host::storage::get("count")?),
|
||||
_ => Err(Error::NotFound(id.into())),
|
||||
}
|
||||
}
|
||||
fn handle_events(&mut self, events: &[Event], _: &Invocation) {
|
||||
let count = host::storage::get("count").ok().flatten().and_then(|n| n.parse::<usize>().ok()).unwrap_or(0);
|
||||
host::storage::set("count", &(count + events.len()).to_string()).unwrap();
|
||||
}
|
||||
}
|
||||
impl HoverProvider for Fixture {
|
||||
fn hover(&mut self, doc: &Document, _: Position) -> Option<Hover> {
|
||||
Some(Hover::new(host::project_of(&doc.path)?.root().ok()?))
|
||||
}
|
||||
}
|
||||
register_plugin!(Fixture, hover);
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
92ca920847c51036ac6dc7acd299ff9afaef9b4037e0112a0159bf50e822c7f3
|
||||
98ab76da97224a698cb20a032d304e545ee168da97273f471030a74288b0f018
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-test-storage"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"macro-string",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-ident",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "openidea-test-storage"
|
||||
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 @@
|
||||
33a5b4ebe6447df38a3c87ab3ead83aac4e2c0a9f08a744527cf90438b366787
|
||||
Loaded 100 of 327 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user