diff --git a/crates/ide-extension/src/ledger.rs b/crates/ide-extension/src/ledger.rs index 19128165a..89a9f9f6c 100644 --- a/crates/ide-extension/src/ledger.rs +++ b/crates/ide-extension/src/ledger.rs @@ -308,6 +308,13 @@ pub fn by_risk(caps: &[Capability]) -> Vec<(Risk, Vec)> { /// 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 { 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 { - 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(); diff --git a/crates/ide-host/src/plugins/endpoints.rs b/crates/ide-host/src/plugins/endpoints.rs new file mode 100644 index 000000000..204768328 --- /dev/null +++ b/crates/ide-host/src/plugins/endpoints.rs @@ -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, +} + +pub(super) fn declaration(pkg: &Package, value: toml::Value) -> Result { + 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 { + 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, + /// Path parameters in braces: `/photos/{id}`. + pub path: String, + pub framework: String, + /// Display name, e.g. `PhotosController#show`. + pub handler: Option, + /// 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>, + 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>> { + 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 = 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, + workspace: &dyn super::PluginWorkspace, +) -> Result, 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> = 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 { + vec![] + } + fn buffer_text(&self, _: &Path) -> Option { + None + } + fn document(&self, _: &Path) -> Option<(u64, String)> { + None + } + fn trusted(&self, _: &Path) -> bool { + true + } + fn project_of(&self, _: &str) -> Option { + None + } + fn apply_edits(&self, _: &Path, _: u64, _: &[ide_core::TextEdit]) -> Result { + unreachable!() + } + fn publish_diagnostics(&self, _: &str, _: &Path, _: Vec) -> 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()); + } + } +} diff --git a/crates/ide-host/src/plugins/index_ops.rs b/crates/ide-host/src/plugins/index_ops.rs index 9047a9ff9..a67127980 100644 --- a/crates/ide-host/src/plugins/index_ops.rs +++ b/crates/ide-host/src/plugins/index_ops.rs @@ -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, + window: &str, + plugin_id: String, +) -> IdeResult> { + 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, diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index 8f47579ad..0aeb50452 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -17,6 +17,7 @@ mod api; pub mod commit; +pub mod endpoints; mod hover; mod icon_extensions; mod icon_patterns; @@ -323,6 +324,7 @@ pub struct PluginHost { /// `[[contributes.providers]]` of enabled plugins. providers: Arc, navigation: Mutex, + endpoints: Mutex, /// Validated UI declarations of installed packages (start-up reuses them). index: Arc, ledger: Mutex, @@ -412,6 +414,7 @@ impl PluginHost { run_detectors, providers, navigation: Mutex::default(), + endpoints: Mutex::default(), index, plugins: RwLock::default(), state: Mutex::new(state), @@ -2085,6 +2088,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(); @@ -2158,6 +2162,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() { @@ -2170,6 +2175,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(); @@ -2179,6 +2185,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); diff --git a/crates/ide-host/src/plugins/points.rs b/crates/ide-host/src/plugins/points.rs index ba8b94f3f..3fcb09b84 100644 --- a/crates/ide-host/src/plugins/points.rs +++ b/crates/ide-host/src/plugins/points.rs @@ -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)))?; diff --git a/crates/ide-host/src/plugins/remote_host.rs b/crates/ide-host/src/plugins/remote_host.rs index ca463134f..31d1462ef 100644 --- a/crates/ide-host/src/plugins/remote_host.rs +++ b/crates/ide-host/src/plugins/remote_host.rs @@ -124,6 +124,7 @@ pub fn forwarded(event: &PluginEvent) -> bool { | PluginEvent::PromptClosed { .. } | PluginEvent::RevealView { .. } | PluginEvent::CommitRequested { .. } + | PluginEvent::EndpointsChanged { .. } ) } @@ -567,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 }); diff --git a/crates/ide-host/src/plugins/types.rs b/crates/ide-host/src/plugins/types.rs index da5885f05..639babe9e 100644 --- a/crates/ide-host/src/plugins/types.rs +++ b/crates/ide-host/src/plugins/types.rs @@ -168,6 +168,10 @@ 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 }, diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 8329111cb..92e1485e2 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -354,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). diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index 1bb51692f..af9bf0d69 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -3353,6 +3353,152 @@ fn invalid_plugin_icons_refuse_the_package() { } } +#[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::>(); + 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, + roots: Mutex>, + entered: Mutex>>, + release: Mutex>, +} +impl PluginWorkspace for EndpointWorkspace { + fn projects(&self) -> Vec { + self.roots.lock().clone() + } + fn buffer_text(&self, path: &Path) -> Option { + 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 { + self.roots.lock().first().cloned() + } + fn apply_edits(&self, path: &Path, version: u64, edits: &[ide_core::TextEdit]) -> Result { + self.inner.apply_edits(path, version, edits) + } + fn publish_diagnostics( + &self, + source: &str, + path: &Path, + items: Vec, + ) -> 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::>(), [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()); diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index b3e26c02c..e0bdf562b 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -16,8 +16,8 @@ use crate::types::{ ViewEvent, ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport, }; use crate::wit_host::{ - bindings, commit_checks_bindings, details_bindings, file_actions_bindings, hover_bindings, indexing_bindings, - inlay_bindings, providers_bindings, semantic_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; @@ -37,6 +37,7 @@ 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; @@ -563,6 +564,7 @@ struct Exports { hover: Option, commit_checks: Option, inlay: Option, + endpoints: Option, } /// Most results one syntax call may return (more are dropped). @@ -648,6 +650,7 @@ impl Runtime { 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 { @@ -690,6 +693,10 @@ impl Runtime { 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 { @@ -703,6 +710,7 @@ impl Runtime { hover, commit_checks, inlay, + endpoints, file_actions, }, limits: setup.limits, @@ -1109,6 +1117,37 @@ impl Runtime { } } + /// Project routes, with an explicit project invocation and background budget. + pub fn endpoints(&mut self, invocation: Invocation, timeout: Duration) -> Result, 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, diff --git a/crates/ide-plugin/src/types.rs b/crates/ide-plugin/src/types.rs index 168753aca..357f459aa 100644 --- a/crates/ide-plugin/src/types.rs +++ b/crates/ide-plugin/src/types.rs @@ -646,6 +646,16 @@ pub enum ViewEvent { Sort { column: u32, descending: bool }, } +/// A plugin's HTTP route declaration in a project. +#[derive(Clone, Debug)] +pub struct Endpoint { + pub method: Option, + pub path: String, + pub framework: String, + pub handler: Option, + 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")] diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs index 9ab31d453..568e3b03c 100644 --- a/crates/ide-plugin/src/wit_host.rs +++ b/crates/ide-plugin/src/wit_host.rs @@ -1040,6 +1040,18 @@ pub(crate) mod inlay_bindings { }); } +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!({ diff --git a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm index 1e0a4b5d6..610b9b9e7 100644 Binary files a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources index f86b4c3bb..65cc7254d 100644 --- a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources @@ -1 +1 @@ -effa9ede0252cc746bffdb3cc092b04749c050cd14263530da37c0c539183288 +f81a6b838d4bee81a058f79436a012f75f55a0c80ad1f10f0af7bd2aba9c32c6 diff --git a/crates/ide-plugin/tests/fixtures/endpoints/Cargo.lock b/crates/ide-plugin/tests/fixtures/endpoints/Cargo.lock new file mode 100644 index 000000000..fb5e979aa --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/endpoints/Cargo.lock @@ -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" diff --git a/crates/ide-plugin/tests/fixtures/endpoints/Cargo.toml b/crates/ide-plugin/tests/fixtures/endpoints/Cargo.toml new file mode 100644 index 000000000..441dff6b6 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/endpoints/Cargo.toml @@ -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" diff --git a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm new file mode 100644 index 000000000..a85348d92 Binary files /dev/null and b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources new file mode 100644 index 000000000..2363d3bf4 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources @@ -0,0 +1 @@ +344660862897f17d7b8f7cdb1397f715201abeab3fc8d7ba1b62c82ca0d9e60b diff --git a/crates/ide-plugin/tests/fixtures/endpoints/plugin.toml b/crates/ide-plugin/tests/fixtures/endpoints/plugin.toml new file mode 100644 index 000000000..2d25637f8 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/endpoints/plugin.toml @@ -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" diff --git a/crates/ide-plugin/tests/fixtures/endpoints/src/lib.rs b/crates/ide-plugin/tests/fixtures/endpoints/src/lib.rs new file mode 100644 index 000000000..eea4238ae --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/endpoints/src/lib.rs @@ -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, Error> { + if id == "crash" { + panic!("endpoint fixture crash"); + } + Err(Error::NotFound(id.into())) + } +} +impl EndpointsProvider for Fixture { + fn endpoints(&mut self, ctx: &Invocation) -> Result, 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); diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm index 015e757dc..37482b7f6 100644 Binary files a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources index 034cd7b3b..3f4dc2f39 100644 --- a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources @@ -1 +1 @@ -4bf56a70aa09995f9acb935434b14954bd89094ba196b645c7e372b3a6403c2e +47c52d929c61fc852f232c0ba5eb798bbdbc9497797cd7f454b32969779d669b diff --git a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm index f5c370a19..04486b8dd 100644 Binary files a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources index 31e2cce52..40bf2f7cf 100644 --- a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources @@ -1 +1 @@ -c2d10319579f7a4b441d00b380a97c24fbc7ad8fac20791c90fe6f864e4f2027 +c8b3c8b074c765a81f8d1096ffe36cd8b93999096b9b77959a4d469e5f6e25f6 diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm index 596b975d1..626ee3d8b 100644 Binary files a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources index d85f352cd..0418a5dbd 100644 --- a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources @@ -1 +1 @@ -003e8d2c175727e7bd0239aed217c8c344e8136b020a214079c530d236d75ec1 +58090f3c530ce4e736b7d182e73d1eaaee7f8db65f76eed0481a00f50828896a diff --git a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm index 32a057954..578f845e3 100644 Binary files a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources index 6d32b97fc..a001ad8cc 100644 --- a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources @@ -1 +1 @@ -8c959561aec1b2e1390d2f9df269f0070af2c5586c906045bf0018078e6b6eed +56b93b6a70821331e1bc835b64b4273d7a2989c66b6623e6a8d097464375e9b3 diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm index 0b8deed22..bf157c042 100644 Binary files a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources index b94a6b5ce..ef2677b58 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources @@ -1 +1 @@ -6485327fb8702f49600cec510359b3aba4c468df53d695f84cb03a9e41b96a89 +ec5a77e097fea4962ffa8227c0bfb247e61d360c5da983c7dabfc84159bf457a diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources index 14438862a..96e1c60f6 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources @@ -1 +1 @@ -951b70f49d8851524bdbe02c870f7ad1ce371358050152af701173816c48c548 +89291c6203a966a5163358ffd64630874d014fe239d7e9747890ebbd8f686540 diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm index 006add95d..10292aee9 100644 Binary files a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources index c722a242a..0b3aeaf03 100644 --- a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources @@ -1 +1 @@ -24e59b772e9d0b3a61fa3ed11f6d0db3dfdf26b228e6f801a0ee0533aa65bd8e +284efc25ade647bd1944024a04a944d63cbc69d8647cc9eff95eb4add0d8509c diff --git a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm index b732a322d..ce1898c67 100644 Binary files a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources index f4afbd269..0f00f1fc0 100644 --- a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources @@ -1 +1 @@ -f62bd72d6ba9d0085473e75e8035ae8e71427ccf6238c7e1a64675653e61aa7d +243ac921ab396b2fc2735bb8552394d90246080264143f1205c59f184db4b504 diff --git a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm index 8e4fba2ca..265487df1 100644 Binary files a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources index fb9532efb..d554de5bb 100644 --- a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources @@ -1 +1 @@ -e0c93213ddc6b7a86f1ea9a82e200facfeb9832d2afbdadef9347e71ee041949 +4d753f6d72e25afee529684219a0c69fcadd33c53a377d503343fb6532ac8979 diff --git a/e2e/pluginEndpoints.spec.ts b/e2e/pluginEndpoints.spec.ts new file mode 100644 index 000000000..a9a222780 --- /dev/null +++ b/e2e/pluginEndpoints.spec.ts @@ -0,0 +1,63 @@ +import { doubleShift, expect, test } from "./fixtures"; + +test("plugin routes feed search, HTTP completion and the declaration gutter and disappear on removal", async ({ + workbench: page, +}) => { + await page.evaluate(async () => { + const c = "/src/workbench/contributions.ts"; + const b = "/src/editor/core/backend.ts"; + const { registerEndpointProvider, editorService } = await import(c); + const { docBackend } = await import(b); + docBackend().files.set("/dev/acme-api/routes.txt", "route declaration\n"); + docBackend().files.set("/dev/acme-api/routes.http", "GET {{host}}/pe"); + (window as unknown as { removeRoutes: () => void }).removeRoutes = registerEndpointProvider({ + id: "plugin:routes:test", + endpoints: async (root: string) => + root === "/dev/acme-api" + ? [ + { + method: "*", + url: "/people/{id}", + framework: "Rails", + handler: "People#show", + pluginId: "routes", + controller: "Rails", + methodName: "People#show", + path: "/dev/acme-api/routes.txt", + rel: "routes.txt", + line: 0, + col: 0, + }, + ] + : [], + }); + await editorService().openFile("/dev/acme-api/routes.txt"); + }); + await expect(page.locator('.cm-openidea-gutter-endpoints button[title="Open in HTTP Client"]')).toBeVisible(); + await doubleShift(page); + const search = page.getByRole("dialog", { name: "Search Everywhere" }); + await search.getByRole("combobox").fill("/people"); + await expect(search.getByRole("option", { name: /people/ })).toBeVisible(); + await page.keyboard.press("Escape"); + await page.evaluate(async () => { + const path = "/src/workbench/contributions.ts"; + await (await import(path)).editorService().openFile("/dev/acme-api/routes.http"); + }); + const content = page.locator(".cm-content").last(); + await content.click(); + await page.keyboard.press("Meta+End"); + await page.keyboard.press("Control+Space"); + await expect(page.locator(".cm-tooltip-autocomplete").getByText("/people/{{id}}", { exact: true })).toBeVisible(); + await page.keyboard.press("Escape"); + await doubleShift(page); + await search.getByRole("combobox").fill("/people"); + await expect(search.getByRole("option", { name: /people/ })).toBeVisible(); + await page.evaluate(() => (window as unknown as { removeRoutes: () => void }).removeRoutes()); + await expect(search.getByRole("option", { name: /people/ })).toBeHidden(); + await page.keyboard.press("Escape"); + await page.evaluate(async () => { + const path = "/src/workbench/contributions.ts"; + await (await import(path)).editorService().openFile("/dev/acme-api/routes.txt"); + }); + await expect(page.locator('.cm-openidea-gutter-endpoints button[title="Open in HTTP Client"]')).toBeHidden(); +}); diff --git a/plugins/README.md b/plugins/README.md index 1097322da..61d491c93 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -1269,6 +1269,39 @@ The call acknowledges the request; cancellation or opening errors are handled by the window. Neither API needs a capability. Paths belong to the UI machine, including when plugin code runs on a remote host. +### Project HTTP endpoints (API 1.3) + +A plugin with `project.read` can export `endpoints-provider` to share its routes +with Search Everywhere's Endpoints section, request URL completion in `.http` +files, and the declaration's **Open in HTTP Client** gutter action. The action +opens the generated-requests scratch; requests start at `http://localhost:8080` +and path parameters become HTTP client variables. Adjust the host for the app. + +```toml +engines.openidea = "^1.3" +[capabilities] +required = ["project.read"] +[[contributes.endpoints]] +files = ["config/routes.rb", "routes/**/*.php"] +``` + +Implement `endpoints::EndpointsProvider` and use +`register_plugin!(MyPlugin, endpoints)` in the Rust SDK. `endpoints(ctx)` receives +an explicit project invocation. It runs the first time the IDE needs that +project's routes, and again after a file matching `files` is created, changed or +removed; in between the host answers from its cache. Return the complete list +(an empty list clears it). `method = None` accepts any method; the optional handler is a display name. +Each endpoint has a required declaration location inside that project, with +zero-based lines and UTF-16 columns. The generated request uses GET for any-method +routes. Calls run in the background and cannot open prompts. + +The host validates a whole batch before replacing the last valid result (up to +10,000 endpoints, 4 MiB of route metadata, 16 MiB of declaration documents). +Disabling, uninstalling, reloading or crashing a plugin withdraws its routes; +idle instance reclamation keeps them cached. Closing a project withdraws its +cache. Windows only receive routes for their current project. This API does not +add an Endpoints tool window. + ### Notifications with commands `host::notify_with_actions(level, title, body, actions)` shows up to three diff --git a/plugins/official/ai-completion/dist/plugin.wasm b/plugins/official/ai-completion/dist/plugin.wasm index edb14100e..41b13eb14 100644 Binary files a/plugins/official/ai-completion/dist/plugin.wasm and b/plugins/official/ai-completion/dist/plugin.wasm differ diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources index bdb860332..45abe7179 100644 --- a/plugins/official/ai-completion/dist/plugin.wasm.sources +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -1 +1 @@ -2c583e91b023c13c13f59dc4e332c7c68296485da3d6febc0648811306e6d136 +10030cb0dc2733d70cd740e717da9f9feebab18e79ac712ebb5bd03c6d40b316 diff --git a/plugins/official/checkstyle/dist/plugin.wasm b/plugins/official/checkstyle/dist/plugin.wasm index dced97eff..113710bcc 100644 Binary files a/plugins/official/checkstyle/dist/plugin.wasm and b/plugins/official/checkstyle/dist/plugin.wasm differ diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index f7806c87f..ede72b964 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -ce627cd77f3e27c1f3dcac9ec8c5470a349a937730db3fee4a2cb327cfa399a1 +9c1afe80f5c8062fa4abd05d3d1cdf73d4ac07b8aa46b329dcbaf6b29d5ec5cf diff --git a/plugins/official/csv-editor/dist/plugin.wasm b/plugins/official/csv-editor/dist/plugin.wasm index e2211c9ed..7b5db1cf8 100644 Binary files a/plugins/official/csv-editor/dist/plugin.wasm and b/plugins/official/csv-editor/dist/plugin.wasm differ diff --git a/plugins/official/csv-editor/dist/plugin.wasm.sources b/plugins/official/csv-editor/dist/plugin.wasm.sources index 235c0be73..f934d73f2 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -976bb84b7ea7e3c3de84ba51a2e7173334fe7a8b316cd1fb8e3e82b51800bf41 +2e0e8d50faede7a1b2ed077de83699adcf519fcbf78d371d01a79ce570c1b85a diff --git a/plugins/official/git-toolbox/dist/plugin.wasm b/plugins/official/git-toolbox/dist/plugin.wasm index 52d7b9fe4..0e75c8192 100644 Binary files a/plugins/official/git-toolbox/dist/plugin.wasm and b/plugins/official/git-toolbox/dist/plugin.wasm differ diff --git a/plugins/official/git-toolbox/dist/plugin.wasm.sources b/plugins/official/git-toolbox/dist/plugin.wasm.sources index 9c15b9a23..024e3ba88 100644 --- a/plugins/official/git-toolbox/dist/plugin.wasm.sources +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -1 +1 @@ -89c9e4cc9d455190d33139651388013f127752b196a29715b651f77b4d629ee5 +c145616a7c143fee66e9808f998577114bfb31effb089cc9e6c843c0d8078696 diff --git a/plugins/official/graphviz/dist/plugin.wasm b/plugins/official/graphviz/dist/plugin.wasm index 4ed139ef8..29c352cd3 100644 Binary files a/plugins/official/graphviz/dist/plugin.wasm and b/plugins/official/graphviz/dist/plugin.wasm differ diff --git a/plugins/official/graphviz/dist/plugin.wasm.sources b/plugins/official/graphviz/dist/plugin.wasm.sources index 94cb748f0..fab3d5b66 100644 --- a/plugins/official/graphviz/dist/plugin.wasm.sources +++ b/plugins/official/graphviz/dist/plugin.wasm.sources @@ -1 +1 @@ -b0ce44f054cb3481cede4bf77a57a3254bf67627cbe617c59632185c20fb687c +65517af4c466e7b470b3d06f6ff67fb27d94bb008184ef1709ba68b8183ee892 diff --git a/plugins/official/key-promoter/dist/plugin.wasm b/plugins/official/key-promoter/dist/plugin.wasm index dfbdd68c6..94d6c2566 100644 Binary files a/plugins/official/key-promoter/dist/plugin.wasm and b/plugins/official/key-promoter/dist/plugin.wasm differ diff --git a/plugins/official/key-promoter/dist/plugin.wasm.sources b/plugins/official/key-promoter/dist/plugin.wasm.sources index 7e650af2a..cb3f47b28 100644 --- a/plugins/official/key-promoter/dist/plugin.wasm.sources +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -1 +1 @@ -aafd97ca04b7e946d0d5a1fee7f0348242a49ebc2e31c43fedc090e83081e74c +d5a3ff93ed282f7c183967113c38d323b332897b1a88d6cd69d1c2324a679852 diff --git a/plugins/official/license-header/dist/plugin.wasm b/plugins/official/license-header/dist/plugin.wasm index 266959871..239765768 100644 Binary files a/plugins/official/license-header/dist/plugin.wasm and b/plugins/official/license-header/dist/plugin.wasm differ diff --git a/plugins/official/license-header/dist/plugin.wasm.sources b/plugins/official/license-header/dist/plugin.wasm.sources index 2bb0b757d..2f706ae66 100644 --- a/plugins/official/license-header/dist/plugin.wasm.sources +++ b/plugins/official/license-header/dist/plugin.wasm.sources @@ -1 +1 @@ -4616c4a6a7c0bea36ca5ef0e0eae1bee283679edc691671b9911a4adec499210 +d2d1cbe6bebead67613348c467a75dddfa7c4c0a22ce2f97caae9590d7f84809 diff --git a/plugins/official/mybatis/dist/plugin.wasm b/plugins/official/mybatis/dist/plugin.wasm index 66fc3eebd..d2663a440 100644 Binary files a/plugins/official/mybatis/dist/plugin.wasm and b/plugins/official/mybatis/dist/plugin.wasm differ diff --git a/plugins/official/mybatis/dist/plugin.wasm.sources b/plugins/official/mybatis/dist/plugin.wasm.sources index 504f78743..3e5f9a1d5 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -61fd51343e7341b05de2f599ba54e9e6b5de3e8c80bdf91d3dad1604cde772bb +a75d13d6d02ff377046aa95a8be642cd8ba3da6d07d03d5730c97bfb5582164a diff --git a/plugins/official/regex-tester/dist/plugin.wasm b/plugins/official/regex-tester/dist/plugin.wasm index beb27e62f..3c9407408 100644 Binary files a/plugins/official/regex-tester/dist/plugin.wasm and b/plugins/official/regex-tester/dist/plugin.wasm differ diff --git a/plugins/official/regex-tester/dist/plugin.wasm.sources b/plugins/official/regex-tester/dist/plugin.wasm.sources index 59d9dc3bd..f6e51b94e 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -e33af7d9f19fe5242b389ccbb820ac737e81c5793e37da67bf743ed57972de19 +6a6bc97f4d23e20f8605acc801d2722162867a5ff61d2295f7447702e26c4268 diff --git a/plugins/official/solidity/dist/plugin.wasm b/plugins/official/solidity/dist/plugin.wasm index 1c1896f2b..4aba760b8 100644 Binary files a/plugins/official/solidity/dist/plugin.wasm and b/plugins/official/solidity/dist/plugin.wasm differ diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources index 13c6569f9..fe62a5e73 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -c571c6a7a4f796631d27faaeaa67b60e0ba9c62e410d4363b5744cac3aa3eda1 +15145ecb6e490604e7184581427c8a6b44af7d6030738b0f9d5eb5ce44dbd949 diff --git a/plugins/official/string-tools/dist/plugin.wasm b/plugins/official/string-tools/dist/plugin.wasm index 15934b232..d4921ebbe 100644 Binary files a/plugins/official/string-tools/dist/plugin.wasm and b/plugins/official/string-tools/dist/plugin.wasm differ diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources index a389f5fa7..ff114fb2c 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -45764bde01e5475ec2eaf67db4b369f405f92800877c72b9f3dbef2e4b317a28 +11b90fde669f51cdc9aac4722d4d0e4bc0c9dbddf2497dff5e67f02a9fa2ccbb diff --git a/sdk/rust/openidea-plugin/src/endpoints.rs b/sdk/rust/openidea-plugin/src/endpoints.rs new file mode 100644 index 000000000..777dd5c54 --- /dev/null +++ b/sdk/rust/openidea-plugin/src/endpoints.rs @@ -0,0 +1,19 @@ +//! Project HTTP routes for Search Everywhere and the HTTP client. +use crate::{Error, Invocation, Location}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Endpoint { + /// `None` accepts any HTTP method. + pub method: Option, + pub path: String, + pub framework: String, + pub handler: Option, + /// Declaration range, with zero-based lines and UTF-16 columns. + pub location: Location, +} + +/// Register with `register_plugin!(MyPlugin, endpoints)` and declare +/// `[[contributes.endpoints]] files = ["config/routes.rb"]` in the manifest. +pub trait EndpointsProvider { + fn endpoints(&mut self, ctx: &Invocation) -> Result, Error>; +} diff --git a/sdk/rust/openidea-plugin/src/lib.rs b/sdk/rust/openidea-plugin/src/lib.rs index 853d85ad0..03c8e24a6 100644 --- a/sdk/rust/openidea-plugin/src/lib.rs +++ b/sdk/rust/openidea-plugin/src/lib.rs @@ -41,6 +41,7 @@ pub mod commit; pub mod details; +pub mod endpoints; pub mod file_actions; pub mod host; pub mod hover; @@ -209,6 +210,22 @@ pub mod file_actions_bindings { }); } +#[cfg(target_arch = "wasm32")] +#[doc(hidden)] +pub mod endpoints_bindings { + wit_bindgen::generate!({ + world: "endpoints", path: "wit", pub_export_macro: true, + export_macro_name: "__export_endpoints_world", + default_bindings_module: "openidea_plugin::endpoints_bindings", + with: { + "openidea:plugin/types@1.3.0": crate::bindings::openidea::plugin::types, + "openidea:plugin/workspace@1.3.0": crate::bindings::openidea::plugin::workspace, + "openidea:plugin/editor@1.3.0": crate::bindings::openidea::plugin::editor, + "openidea:plugin/invocation@1.3.0": crate::bindings::openidea::plugin::invocation, + }, + }); +} + /// The plugin API version this SDK targets. pub const API_VERSION: &str = "1.3"; @@ -269,6 +286,9 @@ macro_rules! __optional_exports { (file_actions, $plugin:ty, $component:ident) => { $crate::__file_actions_exports!($plugin, $component); }; + (endpoints, $plugin:ty, $component:ident) => { + $crate::__endpoints_exports!($plugin, $component); + }; (details, $plugin:ty, $component:ident) => { $crate::__details_exports!($plugin, $component); }; @@ -438,6 +458,22 @@ macro_rules! __indexing_exports { }; } +#[doc(hidden)] +#[macro_export] +macro_rules! __endpoints_exports { + ($plugin:ty, $component:ident) => { + impl $crate::endpoints_bindings::exports::openidea::plugin::endpoints_provider::Guest for $component { + fn endpoints(ctx: &$crate::bindings::openidea::plugin::invocation::Invocation) -> ::core::result::Result< + ::std::vec::Vec<$crate::endpoints_bindings::exports::openidea::plugin::endpoints_provider::Endpoint>, + $crate::bindings::openidea::plugin::types::Error, + > { + $crate::__rt::endpoints::<$plugin>(ctx) + } + } + $crate::endpoints_bindings::__export_endpoints_world!($component with_types_in $crate::endpoints_bindings); + }; +} + /// `register_plugin!(T, details)`: `T` is also a [`details::Details`]. #[doc(hidden)] #[macro_export] @@ -993,6 +1029,28 @@ pub mod __rt { with::(|p| p.render(&view, viewport, &ctx)).map(view_out).map_err(error_out) } + pub fn endpoints( + ctx: &b::invocation::Invocation, + ) -> Result, b::types::Error> + { + use crate::endpoints_bindings::exports::openidea::plugin::endpoints_provider as w; + let ctx = Invocation::__new(ctx); + with::(|p| p.endpoints(&ctx)) + .map(|items| { + items + .into_iter() + .map(|e| w::Endpoint { + method: e.method, + path: e.path, + framework: e.framework, + handler: e.handler, + location: loc(e.location), + }) + .collect() + }) + .map_err(error_out) + } + pub fn service_detail( view: String, key: String, diff --git a/sdk/rust/openidea-plugin/wit/endpoints.wit b/sdk/rust/openidea-plugin/wit/endpoints.wit new file mode 100644 index 000000000..95302fcb0 --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/endpoints.wit @@ -0,0 +1,26 @@ +package openidea:plugin@1.3.0; + +/// HTTP endpoints found in a project (`[[contributes.endpoints]]`). +interface endpoints-provider { + use types.{error, location}; + use invocation.{invocation}; + + /// One HTTP route. + record endpoint { + /// `none` accepts any HTTP method. + method: option, + /// Path parameters use braces, e.g. `/photos/{id}`. + path: string, + /// Shown next to the route, e.g. `Rails`. + framework: string, + /// Display name, e.g. `PhotosController#show`. + handler: option, + /// Where the route is declared, inside the project. + location: location, + } + + /// The project's routes, complete. Called when the IDE first needs them and + /// again after a file matching `files` changes; cached in between. Runs in + /// the background: it can't ask the user. + endpoints: func(ctx: borrow) -> result, error>; +} diff --git a/sdk/rust/openidea-plugin/wit/plugin.wit b/sdk/rust/openidea-plugin/wit/plugin.wit index ad07bbacc..311c4cef6 100644 --- a/sdk/rust/openidea-plugin/wit/plugin.wit +++ b/sdk/rust/openidea-plugin/wit/plugin.wit @@ -140,3 +140,13 @@ world commit-checks { world file-actions-provider { export file-actions; } + +/// A plugin with project HTTP endpoints (`[[contributes.endpoints]]`). +world plugin-endpoints { + include plugin; + export endpoints-provider; +} + +world endpoints { + export endpoints-provider; +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index f4a7f1d93..7aeb867a9 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -507,6 +507,7 @@ pub fn builder() -> tauri_specta::Builder { plugin::plugin_navigation_index, plugin::plugin_navigation_goto, plugin::plugin_navigation_markers, + plugin::plugin_endpoints, plugin::plugin_market, plugin::plugin_market_url, plugin::plugin_set_market_url, diff --git a/src-tauri/src/commands/plugin.rs b/src-tauri/src/commands/plugin.rs index 472d55869..2d69c0edd 100644 --- a/src-tauri/src/commands/plugin.rs +++ b/src-tauri/src/commands/plugin.rs @@ -787,6 +787,16 @@ pub async fn plugin_navigation_goto( .await } +#[tauri::command] +#[specta::specta] +pub async fn plugin_endpoints( + window: Window, + state: State<'_, AppState>, + plugin_id: String, +) -> IdeResult> { + ide_host::plugins::index_ops::plugin_endpoints(&state.host, state.plugins.clone(), window.label(), plugin_id).await +} + #[tauri::command] #[specta::specta] pub async fn plugin_navigation_markers( diff --git a/src/bindings.ts b/src/bindings.ts index 502c4acad..959a85122 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1143,6 +1143,7 @@ export const commands = { targets: Location[], } | null, IdeError>(__TAURI_INVOKE("plugin_navigation_goto", { doc, pos })), pluginNavigationMarkers: (paths: string[]) => typedError(__TAURI_INVOKE("plugin_navigation_markers", { paths })), + pluginEndpoints: (pluginId: string) => typedError(__TAURI_INVOKE("plugin_endpoints", { pluginId })), /** The marketplace's plugins: read again when `refresh` or when none was read yet, else the last index read. */ pluginMarket: (refresh: boolean) => typedError(__TAURI_INVOKE("plugin_market", { refresh })), pluginMarketUrl: () => __TAURI_INVOKE("plugin_market_url"), @@ -5950,12 +5951,30 @@ export type PluginEditor = { selections: Range[], }; +/** A route as the UI gets it. */ +export type PluginEndpoint = { + /** `None` accepts any method. */ + method: string | null, + /** Path parameters in braces: `/photos/{id}`. */ + path: string, + framework: string, + /** Display name, e.g. `PhotosController#show`. */ + handler: string | null, + /** Where the route is declared. */ + location: Location, +}; + /** Backend → UI notifications (Tauri event `ide://plugin`). */ export type PluginEvent = /** Plugins, their status or their contributions changed: re-fetch `plugin_list`. */ { type: "commandsChanged" } | /** Cached document associations were disposed (reload, disable or failure). */ { type: "documentIndexesChanged" } | +/** + * The routes `plugin_id` reports for the project `root` may have changed: + * ask `plugin_endpoints` again. + */ +{ type: "endpointsChanged"; pluginId: string; root: IpcPath } | /** * The marketplace offers newer versions of installed plugins (announced * once per set of versions). diff --git a/src/editor/core/backend.ts b/src/editor/core/backend.ts index 4407f1b25..a1b294ef8 100644 --- a/src/editor/core/backend.ts +++ b/src/editor/core/backend.ts @@ -118,6 +118,8 @@ interface MemDoc { } const EXT_LANG: Record = { + http: "http", + rest: "http", ts: "typescript", mts: "typescript", cts: "typescript", diff --git a/src/features/http-client/endpoints.test.ts b/src/features/http-client/endpoints.test.ts new file mode 100644 index 000000000..6d96ca826 --- /dev/null +++ b/src/features/http-client/endpoints.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { CompletionContext } from "@codemirror/autocomplete"; +import { EditorState } from "@codemirror/state"; +import { useProject } from "@/platform/project"; +import { registerEndpointProvider, searchEndpoints, type EndpointEntry } from "@/workbench/contributions"; +import { completionTarget, endpointCompletion, renderEndpoint } from "./endpoints"; + +const route: EndpointEntry = { + method: "GET", + url: "/users/{id}", + controller: "", + methodName: "users.show", + handler: "users.show", + path: "/p/routes.txt", + rel: "", + line: 0, + col: 0, +}; + +describe("shared HTTP endpoints", () => { + it("completes only request URLs, preserving origins and variables", () => { + for (const url of ["/us", "http://localhost:8000/us", "https://example.com/us", "{{host}}/us"]) { + const text = `### Users\nGET ${url}`; + const result = completionTarget(text, text.length)!; + expect(text.slice(result.from)).toBe("/us"); + expect(result.method).toBe("GET"); + } + for (const text of [ + "GET /users\nX-Test: /us", + "POST /users\n\nGET /us", + "# GET /us", + "@host = /us", + "GET /users HTTP/1.1", + ]) { + expect(completionTarget(text, text.length)).toBeNull(); + } + expect(completionTarget("GET /old\n### Next\nPOST /us", 28)?.method).toBe("POST"); + }); + + it("renders optional handlers and any-method routes into HTTP blocks", () => { + expect(renderEndpoint(route)).toBe("### users.show\nGET http://localhost:8080/users/{{id}}\n"); + expect(renderEndpoint({ ...route, method: "*", handler: undefined })).toBe( + "### * /users/{id}\nGET http://localhost:8080/users/{{id}}\n", + ); + }); + + it("scopes providers and removes their completion entries on disposal", async () => { + const previous = useProject.getState(); + useProject.setState({ project: { root: "/p" } as NonNullable }); + const dispose = registerEndpointProvider({ + id: "test.routes", + endpoints: async (root) => (root === "/p" ? [route, { ...route, method: "POST" }] : []), + }); + const broken = registerEndpointProvider({ + id: "test.failed", + endpoints: () => { + throw new Error("provider unavailable"); + }, + }); + try { + const state = EditorState.create({ doc: "GET {{host}}/us" }); + const complete = () => endpointCompletion(new CompletionContext(state, state.doc.length, true)); + expect((await complete())?.options.map((o) => o.label)).toEqual(["/users/{{id}}"]); + expect(await searchEndpoints("/other")).toEqual([]); + dispose(); + expect((await complete())?.options).toEqual([]); + } finally { + dispose(); + broken(); + useProject.setState(previous); + } + }); +}); diff --git a/src/features/http-client/endpoints.ts b/src/features/http-client/endpoints.ts new file mode 100644 index 000000000..161481966 --- /dev/null +++ b/src/features/http-client/endpoints.ts @@ -0,0 +1,163 @@ +import type { CompletionContext, CompletionResult } from "@codemirror/autocomplete"; +import { EditorState, RangeSet, StateEffect, StateField, type Extension } from "@codemirror/state"; +import { EditorView, GutterMarker, ViewPlugin } from "@codemirror/view"; +import { gutter } from "@codemirror/view"; +import type { EditorDocContext } from "@/editor/api"; +import { executeAction } from "@/platform/actions"; +import { tx } from "@/platform/i18n"; +import { useProject } from "@/platform/project"; +import { gutterIconMarkup, showContextMenu } from "@/ui"; +import { notifications, onEndpointsChanged, searchEndpoints, type EndpointEntry } from "@/workbench/contributions"; +import { appendGeneratedRequests } from "./generatedRequests"; +import { METHODS } from "./language"; +import styles from "./HttpClient.module.css"; + +export const OPEN_ENDPOINT = "http.openEndpoint"; + +export const endpointUrl = (path: string) => path.replace(/\{(?!\{)([\w.-]+)\}(?!\})/g, "{{$1}}"); + +export function renderEndpoint(e: EndpointEntry): string { + return `### ${e.handler ?? `${e.method} ${e.url}`}\n${e.method === "*" ? "GET" : e.method} http://localhost:8080${endpointUrl(e.url)}\n`; +} + +export async function openEndpoint(endpoint: EndpointEntry): Promise { + try { + await appendGeneratedRequests([renderEndpoint(endpoint)]); + } catch (e) { + notifications().notify({ level: "error", title: tx("Cannot generate the HTTP request"), body: String(e) }); + } +} + +/** Only the URL of the first request line in a request block is completed. */ +export function completionTarget(text: string, pos: number): { from: number; method: string; prefix: string } | null { + const before = text.slice(0, pos); + const start = before.lastIndexOf("\n") + 1; + const line = before.slice(start); + const m = new RegExp(`^\\s*(${METHODS.join("|")})\\s+(\\S*)$`).exec(line); + if (!m) return null; + const block = + before + .slice(0, start) + .split(/^\s*###.*$/m) + .at(-1) ?? ""; + if (block.split("\n").some((l) => new RegExp(`^\\s*(?:${METHODS.join("|")})\\s+|^\\s*https?://`).test(l))) + return null; + const url = m[2]!; + const origin = /^(?:https?:\/\/[^/\s]+|\{\{[^}]+\}\})(?=\/|$)/.exec(url)?.[0] ?? ""; + if (url && !origin && !url.startsWith("/")) return null; + return { from: pos - url.length + origin.length, method: m[1]!, prefix: url.slice(origin.length) }; +} + +export async function endpointCompletion(context: CompletionContext): Promise { + const target = completionTarget(context.state.doc.toString(), context.pos); + if (!target) return null; + const root = useProject.getState().project?.root ?? null; + const endpoints = await searchEndpoints(root); + if (context.aborted || root !== (useProject.getState().project?.root ?? null)) return null; + const seen = new Set(); + return { + from: target.from, + options: endpoints + .filter((e) => { + if ((e.method !== "*" && e.method !== target.method) || !e.url.startsWith(target.prefix) || seen.has(e.url)) + return false; + seen.add(e.url); + return true; + }) + .map((e) => ({ label: endpointUrl(e.url), detail: e.handler ?? e.framework, type: "url" })), + }; +} + +type Mark = { pos: number; endpoints: EndpointEntry[] }; +const setMarks = StateEffect.define(); +const marks = StateField.define({ + create: () => [], + update: (value, tr) => { + for (const e of tr.effects) if (e.is(setMarks)) return e.value; + return tr.docChanged ? [] : value; + }, +}); + +class EndpointMarker extends GutterMarker { + override toDOM(): Node { + const button = document.createElement("button"); + button.type = "button"; + button.className = styles.runMarker ?? ""; + button.title = tx("Open in HTTP Client"); + button.setAttribute("aria-label", button.title); + button.innerHTML = gutterIconMarkup("file-http") ?? ""; + return button; + } +} + +export function endpointEditorExtension(ctx: EditorDocContext): Extension { + if (ctx.language === "http") return EditorState.languageData.of(() => [{ openideaCompletion: endpointCompletion }]); + if (ctx.virtual) return []; + return [ + marks, + ViewPlugin.fromClass( + class { + seq = 0; + destroyed = false; + unsubscribe: () => void; + projectUnsubscribe: () => void; + constructor(readonly view: EditorView) { + this.unsubscribe = onEndpointsChanged(() => this.refresh()); + this.projectUnsubscribe = useProject.subscribe(() => this.refresh()); + this.refresh(); + } + update(u: { docChanged: boolean }) { + if (u.docChanged) this.refresh(); + } + refresh() { + const seq = ++this.seq; + const root = useProject.getState().project?.root ?? null; + void searchEndpoints(root).then((entries) => { + if (this.destroyed || seq !== this.seq) return; + const byLine = new Map(); + for (const e of entries) { + if (!e.pluginId || e.path !== ctx.path || e.line >= this.view.state.doc.lines) continue; + const pos = this.view.state.doc.line(e.line + 1).from; + byLine.set(pos, [...(byLine.get(pos) ?? []), e]); + } + this.view.dispatch({ effects: setMarks.of([...byLine].map(([pos, endpoints]) => ({ pos, endpoints }))) }); + }); + } + destroy() { + this.destroyed = true; + this.unsubscribe(); + this.projectUnsubscribe(); + } + }, + ), + gutter({ + class: `${styles.runGutter} cm-openidea-gutter-endpoints`, + markers: (view) => + RangeSet.of( + view.state.field(marks).map((m) => new EndpointMarker().range(m.pos)), + true, + ), + domEventHandlers: { + mousedown: (view, line, event) => { + if ((event as MouseEvent).button !== 0) return false; + const found = view.state.field(marks).find((m) => m.pos === line.from); + if (!found) return false; + event.preventDefault(); + const rect = (event.target as HTMLElement).getBoundingClientRect(); + showContextMenu( + { x: rect.left, y: rect.bottom }, + found.endpoints.map((e) => ({ + label: `${e.method} ${e.url}`, + verbatim: true, + icon: "file-http", + run: () => { + void executeAction(OPEN_ENDPOINT, e); + }, + })), + ); + return true; + }, + }, + }), + ]; +} diff --git a/src/features/http-client/generatedRequests.ts b/src/features/http-client/generatedRequests.ts new file mode 100644 index 000000000..67998f9f1 --- /dev/null +++ b/src/features/http-client/generatedRequests.ts @@ -0,0 +1,60 @@ +import { EditorView } from "@codemirror/view"; +import { activeEditor, editorsFor, useEditorRegistry, type TextEditorHandle } from "@/editor/api"; +import { tx } from "@/platform/i18n"; +import { call, commands, emitLocalIdeEvent, IpcError } from "@/platform/ipc"; +import { ipcPath } from "@/platform/paths"; +import { editorService } from "@/workbench/contributions"; +import { appendRequests } from "./spring/request"; + +export const GENERATED_REQUESTS = "generated-requests.http"; + +async function scratchFile(): Promise<{ path: string; created: boolean }> { + const { appDataDir } = await import("@tauri-apps/api/path"); + // `appDataDir()` is native (`C:\…`): the editor is found by its `/` path. + const dir = `${ipcPath(await appDataDir()).replace(/\/+$/, "")}/scratches`; + const ignoreExisting = (e: unknown) => { + if (!(e instanceof IpcError && e.kind === "alreadyExists")) throw e; + }; + await call(commands.fsCreateDir(dir)).catch(ignoreExisting); + const path = `${dir}/${GENERATED_REQUESTS}`; + let created = true; + await call(commands.fsCreateFile(path)).catch((e) => { + ignoreExisting(e); + created = false; + }); + return { path, created }; +} + +/** The text editor of `path`, once the just opened tab has created it. */ +function editorFor(path: string, timeoutMs = 5000): Promise { + const find = () => editorsFor(path).find((x) => x === activeEditor()) ?? editorsFor(path)[0] ?? null; + const now = find(); + if (now) return Promise.resolve(now); + return new Promise((resolve) => { + const done = (e: TextEditorHandle | null) => { + clearTimeout(timer); + unsubscribe(); + resolve(e); + }; + const timer = setTimeout(() => done(null), timeoutMs); + const unsubscribe = useEditorRegistry.subscribe(() => { + const e = find(); + if (e) done(e); + }); + }); +} + +export async function appendGeneratedRequests(blocks: string[]): Promise { + const file = await scratchFile(); + if (file.created) emitLocalIdeEvent({ type: "fsChanged", created: [file.path], modified: [], removed: [] }); + await editorService().openFile(file.path, { focus: true }); + const e = await editorFor(file.path); + if (!e) throw new Error(`${tx("Cannot open file")}: ${file.path}`); + const { insert, from, caret } = appendRequests(e.view.state.doc.toString(), blocks); + e.view.dispatch({ + changes: { from, insert }, + selection: { anchor: caret }, + effects: EditorView.scrollIntoView(caret, { y: "center" }), + userEvent: "input", + }); +} diff --git a/src/features/http-client/index.ts b/src/features/http-client/index.ts index a745a95a8..90f6a4581 100644 --- a/src/features/http-client/index.ts +++ b/src/features/http-client/index.ts @@ -28,6 +28,8 @@ import { menuGroupItems, springEditorExtension, } from "./spring/editor"; +import { endpointEditorExtension, OPEN_ENDPOINT, openEndpoint } from "./endpoints"; +import type { EndpointEntry } from "@/workbench/contributions"; import { registerSpringEndpointProvider } from "./spring/routeIndex"; const SPRING_WHEN = "editorLangId == java || editorLangId == kotlin"; @@ -119,6 +121,7 @@ export function activate(): () => void { }), registerLanguageMode(httpLanguageDescription), registerEditorContribution({ id: "http.requests", order: 56, extensions: httpEditorExtension }), + registerEditorContribution({ id: "http.endpoints", order: 58, extensions: endpointEditorExtension }), registerEditorContribution({ id: "http.spring", order: 57, extensions: springEditorExtension }), registerMenuGroup({ id: "http.spring", menus: ["editor"], order: 40, items: (_menu, ctx) => menuGroupItems(ctx) }), registerServiceView({ @@ -138,6 +141,15 @@ export function activate(): () => void { directory ? [{ kind: "action", action: "http.newRequest", arg: { directory }, label: "HTTP Request" }] : [], }), registerActions([ + { + id: OPEN_ENDPOINT, + title: "Open in HTTP Client", + category: "Tools", + icon: "file-http", + run: async ({ arg }) => { + if (arg) await openEndpoint(arg as EndpointEntry); + }, + }, { id: "http.runRequest", title: "Run HTTP Request at Caret", diff --git a/src/features/http-client/spring/editor.ts b/src/features/http-client/spring/editor.ts index a594af1bd..b807de4db 100644 --- a/src/features/http-client/spring/editor.ts +++ b/src/features/http-client/spring/editor.ts @@ -5,29 +5,19 @@ */ import { RangeSet, StateEffect, StateField, type EditorState, type Extension } from "@codemirror/state"; import { EditorView, GutterMarker, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view"; -import { - activeEditor, - editorsFor, - intentionProviders, - useEditorRegistry, - type ContributedIntention, - type EditorDocContext, - type TextEditorHandle, -} from "@/editor/api"; -import { tx } from "@/platform/i18n"; -import { call, commands, emitLocalIdeEvent, IpcError } from "@/platform/ipc"; -import { ipcPath } from "@/platform/paths"; +import { activeEditor, intentionProviders, type ContributedIntention, type EditorDocContext } from "@/editor/api"; +import { appendGeneratedRequests } from "../generatedRequests"; +import { call, commands } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { isVirtualUri } from "@/platform/virtualDocs"; import { gutterIconMarkup, showContextMenu, type MenuItem } from "@/ui"; -import { editorService, notifications, type MenuGroupContext } from "@/workbench/contributions"; +import { notifications, type MenuGroupContext } from "@/workbench/contributions"; import { springAt, springControllers, type Controller, type Endpoint } from "./endpoints"; -import { appendRequests, bodyLookup, findHost, renderRequest, skeleton, type SourceReader } from "./request"; +import { bodyLookup, findHost, renderRequest, skeleton, type SourceReader } from "./request"; import { parseSource } from "./source"; import styles from "../HttpClient.module.css"; export const SPRING_LANGUAGES = new Set(["java", "kotlin"]); -export const GENERATED_REQUESTS = "generated-requests.http"; export const GENERATE_REQUEST = "http.generateSpringRequest"; export const GENERATE_CONTROLLER_REQUESTS = "http.generateSpringControllerRequests"; @@ -47,42 +37,6 @@ export interface Target { path?: string; } -async function scratchFile(): Promise<{ path: string; created: boolean }> { - const { appDataDir } = await import("@tauri-apps/api/path"); - // `appDataDir()` is native (`C:\…`): the editor is found by its `/` path. - const dir = `${ipcPath(await appDataDir()).replace(/\/+$/, "")}/scratches`; - const ignoreExisting = (e: unknown) => { - if (!(e instanceof IpcError && e.kind === "alreadyExists")) throw e; - }; - await call(commands.fsCreateDir(dir)).catch(ignoreExisting); - const path = `${dir}/${GENERATED_REQUESTS}`; - let created = true; - await call(commands.fsCreateFile(path)).catch((e) => { - ignoreExisting(e); - created = false; - }); - return { path, created }; -} - -/** The text editor of `path`, once the just opened tab has created it. */ -function editorFor(path: string, timeoutMs = 5000): Promise { - const find = () => editorsFor(path).find((x) => x === activeEditor()) ?? editorsFor(path)[0] ?? null; - const now = find(); - if (now) return Promise.resolve(now); - return new Promise((resolve) => { - const done = (e: TextEditorHandle | null) => { - clearTimeout(timer); - unsubscribe(); - resolve(e); - }; - const timer = setTimeout(() => done(null), timeoutMs); - const unsubscribe = useEditorRegistry.subscribe(() => { - const e = find(); - if (e) done(e); - }); - }); -} - /** Renders `targets` of the source at `sourcePath`, appends them to the scratch and opens it at the first one. */ export async function generateRequests(sourcePath: string, text: string, targets: Target[]): Promise { if (!targets.length) return; @@ -104,18 +58,7 @@ export async function generateRequests(sourcePath: string, text: string, targets body: endpoint.bodyType ? skeleton(endpoint.bodyType, lookup) : undefined, }), ); - const file = await scratchFile(); - if (file.created) emitLocalIdeEvent({ type: "fsChanged", created: [file.path], modified: [], removed: [] }); - await editorService().openFile(file.path, { focus: true }); - const e = await editorFor(file.path); - if (!e) throw new Error(`${tx("Cannot open file")}: ${file.path}`); - const { insert, from, caret } = appendRequests(e.view.state.doc.toString(), blocks); - e.view.dispatch({ - changes: { from, insert }, - selection: { anchor: caret }, - effects: EditorView.scrollIntoView(caret, { y: "center" }), - userEvent: "input", - }); + await appendGeneratedRequests(blocks); } catch (err) { notifications().notify({ level: "error", diff --git a/src/features/plugins/__tests__/plugins.test.tsx b/src/features/plugins/__tests__/plugins.test.tsx index 5407f6832..9e21553ab 100644 --- a/src/features/plugins/__tests__/plugins.test.tsx +++ b/src/features/plugins/__tests__/plugins.test.tsx @@ -10,7 +10,12 @@ import { useProject } from "@/platform/project"; import { pluginThemeRef, useContributedIconThemes } from "@/platform/themes"; import { ContextMenuHost, hideContextMenu } from "@/ui"; import { InputDialogsHost } from "@/workbench/inputDialogs"; -import { provideNotificationService, type NotificationService } from "@/workbench/contributions"; +import { + onEndpointsChanged, + provideNotificationService, + searchEndpoints, + type NotificationService, +} from "@/workbench/contributions"; import { applyThemeAttributes, previewTheme, useThemeOverride } from "@/workbench/theme"; import { createMemoryPluginsBackend, sampleCatalog, setPluginsBackend, type PluginsBackend } from "../backend"; import { @@ -1292,3 +1297,37 @@ describe("plugin context keys", () => { await waitFor(() => expect(evaluateWhen("plugin.p.f")).toBe(true)); }); }); + +it("asks the plugin for its routes once per plugin and again when they change", async () => { + const plugins = [plugin({ id: "routes" })]; + const endpoints = vi.fn(async () => [ + { + method: null, + path: "/users/{id}", + framework: "Rails", + handler: "Users#show", + location: { path: "/p/config/routes.rb", range: { start: { line: 2, col: 4 }, end: { line: 2, col: 8 } } }, + }, + ]); + setPluginsBackend({ ...createMemoryPluginsBackend(), endpoints }); + const decl = (key: string) => ({ pluginId: "routes", point: "endpoints", key, decl: { files: [key] } }); + syncUiContributions([decl("config/routes.rb"), decl("routes/*.rb")], plugins); + expect(await searchEndpoints("/p")).toMatchObject([ + { method: "*", url: "/users/{id}", handler: "Users#show", rel: "config/routes.rb", line: 2, col: 4 }, + ]); + expect(endpoints).toHaveBeenCalledWith("routes"); + syncUiContributions([decl("config/routes.rb")], plugins); + expect(await searchEndpoints("/p")).toHaveLength(1); + + const changed = vi.fn(); + const stop = onEndpointsChanged(changed); + useProject.setState({ project: { root: "/p" } as never }); + handlePluginEvent({ type: "endpointsChanged", pluginId: "routes", root: "/other" }); + expect(changed).not.toHaveBeenCalled(); + handlePluginEvent({ type: "endpointsChanged", pluginId: "routes", root: "/p" }); + expect(changed).toHaveBeenCalledTimes(1); + stop(); + + syncUiContributions([], plugins); + expect(await searchEndpoints("/p")).toEqual([]); +}); diff --git a/src/features/plugins/backend.ts b/src/features/plugins/backend.ts index e753e145b..154b3d588 100644 --- a/src/features/plugins/backend.ts +++ b/src/features/plugins/backend.ts @@ -20,11 +20,12 @@ import type { PluginCommitCheckResult, PluginContextKey, PluginEditor, + PluginEndpoint, PluginEvent, PluginInfo, PluginOutputLine, - PluginsSnapshot, PluginSecretState, + PluginsSnapshot, PluginStatusItem, ServiceTab, UiContribution, @@ -101,6 +102,8 @@ export interface PluginsBackend { viewEvent(pluginId: string, view: string, event: ViewEvent, doc: number | null): Promise; /** The Services pane of node `key` of a plugin's services view. */ serviceDetail(pluginId: string, view: string, key: string): Promise; + /** The HTTP routes a plugin reports for this window's project (`[[contributes.endpoints]]`). */ + endpoints(pluginId: string): Promise; /** * Runs the program of node `key`'s log tab `tab` (with the toggles in `on`) and streams its * lines until it exits (`onExit`) or the returned stop function is called. @@ -235,6 +238,7 @@ export const tauriPluginsBackend: PluginsBackend = { await call(commands.pluginViewEvent(pluginId, view, event, doc, doc == null ? editorContext() : null)); }, serviceDetail: (pluginId, view, key) => call(commands.pluginServiceDetail(pluginId, view, key)), + endpoints: (pluginId) => call(commands.pluginEndpoints(pluginId)), serviceLog: (pluginId, view, key, tab, on, onLine, onError, onExit) => { const request = nextRequestId(); const channel = new Channel(); @@ -588,6 +592,7 @@ export function createMemoryPluginsBackend( }), viewEvent: async () => {}, serviceDetail: async () => [], + endpoints: async () => [], answerCommit: async () => {}, commitChecks: async () => [], serviceLog: (pluginId, view, key, _tab, _on, _onLine, onError) => { diff --git a/src/features/plugins/bridge.ts b/src/features/plugins/bridge.ts index 2b8690aa1..0a81aeb9a 100644 --- a/src/features/plugins/bridge.ts +++ b/src/features/plugins/bridge.ts @@ -9,7 +9,7 @@ import type { ActiveTheme, PluginEvent, PluginUpdate } from "@/bindings"; import { useEditorRegistry } from "@/editor/api"; import { refreshLanguageInfos } from "@/editor/core/cm/languages"; -import { notifications } from "@/workbench/contributions"; +import { endpointsChanged, notifications } from "@/workbench/contributions"; import { useI18n } from "@/platform/i18n"; import { useProducerNames } from "@/platform/inspections"; import { languageListChanged } from "@/platform/languageList"; @@ -280,6 +280,9 @@ export function handlePluginEvent(e: PluginEvent) { case "commandsChanged": scheduleRefresh(); break; + case "endpointsChanged": + if (e.root === useProject.getState().project?.root) endpointsChanged(); + break; case "updatesAvailable": announceUpdates(e.updates); break; diff --git a/src/features/plugins/points.tsx b/src/features/plugins/points.tsx index aff27805a..3e3f5cc2f 100644 --- a/src/features/plugins/points.tsx +++ b/src/features/plugins/points.tsx @@ -3,6 +3,7 @@ * declaration is projected onto the workbench, and how its items appear on * the plugin details page. One `defineUiPoint` per contribution type. */ +import { registerEndpointProvider, type EndpointEntry } from "@/workbench/contributions"; import type { PluginInfo, ThemeDef } from "@/bindings"; import { registerAction } from "@/platform/actions"; import { tx } from "@/platform/i18n"; @@ -14,6 +15,7 @@ import { addIconTheme, iconThemeKey, selectIconTheme, useSelectedIconTheme } fro import { defineUiPoint, type UiItem } from "./projection"; import { SecretsDetails, type SecretDecl } from "./secrets"; import { defineViewUiPoints, pluginSettingsPageId } from "./uiPoints"; +import { pluginsBackend } from "./backend"; import { isLive, runPluginCommand } from "./store"; import styles from "./PluginsPage.module.css"; @@ -141,9 +143,45 @@ function IconThemesDetails({ plugin, items }: { plugin: PluginInfo; items: UiIte ); } +const endpointPlugins = new Map void }>(); + +/** One endpoint provider per plugin, however many `[[contributes.endpoints]]` it declares. */ +function providePluginEndpoints(pluginId: string): () => void { + let entry = endpointPlugins.get(pluginId); + if (!entry) { + const dispose = registerEndpointProvider({ + id: `plugin:${pluginId}`, + endpoints: async (root): Promise => + (await pluginsBackend().endpoints(pluginId)).map((e) => ({ + method: e.method ?? "*", + url: e.path, + framework: e.framework, + handler: e.handler ?? undefined, + controller: e.framework, + methodName: e.handler ?? "", + pluginId, + path: e.location.path, + rel: e.location.path.startsWith(`${root}/`) ? e.location.path.slice(root.length + 1) : e.location.path, + line: e.location.range.start.line, + col: e.location.range.start.col, + })), + }); + entry = { declarations: 0, dispose }; + endpointPlugins.set(pluginId, entry); + } + const held = entry; + held.declarations++; + return () => { + if (--held.declarations > 0) return; + held.dispose(); + endpointPlugins.delete(pluginId); + }; +} + /** Registers the client side of every first-party UI point. */ export function defineBuiltinUiPoints(): () => void { const disposers = [ + defineUiPoint("endpoints", { project: ({ pluginId }) => providePluginEndpoints(pluginId) }), defineUiPoint("commands", { title: "Commands", project: ({ pluginId, decl }, { plugin }) => diff --git a/src/features/search/__tests__/routes.test.ts b/src/features/search/__tests__/routes.test.ts index efd0d1ecf..ee1766b53 100644 --- a/src/features/search/__tests__/routes.test.ts +++ b/src/features/search/__tests__/routes.test.ts @@ -26,6 +26,13 @@ const routes: EndpointEntry[] = [ ]; describe("endpoints in Search Everywhere", () => { + it("finds any-method plugin routes and displays an optional handler or framework", () => { + const entry = route({ method: "*", controller: "Rails", methodName: "", framework: "Rails", handler: undefined }); + expect(matchRoutes([entry], "GET /deeplink/menu/search", 5).items[0]?.container).toBe("Rails"); + expect(matchRoutes([{ ...entry, handler: "Menus#search" }], "Menus#search", 5).items[0]?.container).toBe( + "Menus#search", + ); + }); it("finds a controller method by the request path it answers", () => { for (const query of [ "/deeplink/menu/search", diff --git a/src/features/search/everywhere/SearchEverywhere.tsx b/src/features/search/everywhere/SearchEverywhere.tsx index c629fb680..c98cb7ec2 100644 --- a/src/features/search/everywhere/SearchEverywhere.tsx +++ b/src/features/search/everywhere/SearchEverywhere.tsx @@ -1,3 +1,4 @@ +import { onEndpointsChanged } from "@/workbench/contributions"; import { useFilePresentation } from "@/platform/filePresentation"; import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from "react"; import clsx from "clsx"; @@ -140,6 +141,15 @@ function SearchEverywhereDialog() { const bumpToggles = () => setToggleTick((t) => t + 1); useEffect(() => () => controller.dispose(), [controller]); + const [endpointVersion, setEndpointVersion] = useState(0); + useEffect( + () => + onEndpointsChanged(() => { + controller.invalidateEndpoints(); + setEndpointVersion((v) => v + 1); + }), + [controller], + ); useEffect(() => { if (query === debounced) return; @@ -182,7 +192,7 @@ function SearchEverywhereDialog() { includeDisabled: tab === "actions" ? includeNonProject : undefined, extra, }); - }, [controller, searchQuery, tab, includeNonProject, fileSet, filter, extra]); + }, [controller, searchQuery, tab, includeNonProject, fileSet, filter, extra, endpointVersion]); useEffect(() => { inputRef.current?.select(); diff --git a/src/features/search/everywhere/controller.ts b/src/features/search/everywhere/controller.ts index b63c8979f..9959660d4 100644 --- a/src/features/search/everywhere/controller.ts +++ b/src/features/search/everywhere/controller.ts @@ -111,6 +111,10 @@ export class SeController { private readonly onChange: (s: SeSnapshot) => void, ) {} + invalidateEndpoints() { + this.signatures.delete("endpoints"); + } + run(req: SeRequest) { const gen = ++this.gen; this.disposed = false; diff --git a/src/features/search/everywhere/routes.ts b/src/features/search/everywhere/routes.ts index f6c68a0fa..bf4ab8237 100644 --- a/src/features/search/everywhere/routes.ts +++ b/src/features/search/everywhere/routes.ts @@ -15,7 +15,7 @@ function item(r: EndpointEntry, indices: number[], score: number): SymbolItem { name: display, display, symbolKind: "method", - container: `${r.controller}.${r.methodName}`, + container: r.handler ?? [r.controller, r.methodName].filter(Boolean).join("."), path: r.path, rel: r.rel, line: r.line, @@ -47,9 +47,9 @@ export function matchRoutes( const hits: { item: SymbolItem; score: number }[] = []; for (const r of routes) { - if (method && r.method !== method) continue; + if (method && r.method !== "*" && r.method !== method) continue; const target = strip(r.url).toLowerCase(); - const qualified = `${r.controller}.${r.methodName}`.toLowerCase(); + const qualified = (r.handler ?? [r.controller, r.methodName].filter(Boolean).join(".")).toLowerCase(); let score = 0; let indices: number[] = []; if (wantPath) { diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 8c0e04aff..417d5750f 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -1,4 +1,13 @@ { + "Install 'openidea' Command": "Install 'openidea' Command", + "Uninstall 'openidea' Command": "Uninstall 'openidea' Command", + "The openidea command is installed": "The openidea command is installed", + "The openidea command is uninstalled": "The openidea command is uninstalled", + "The openidea command is not installed": "The openidea command is not installed", + "Cannot install the openidea command": "Cannot install the openidea command", + "Cannot uninstall the openidea command": "Cannot uninstall the openidea command", + "Open a new terminal to use the openidea command.": "Open a new terminal to use the openidea command.", + "Add {path} to the beginning of PATH, then open a new terminal.": "Add {path} to the beginning of PATH, then open a new terminal.", "OpenIDEA or JetBrains settings": "OpenIDEA or JetBrains settings", "Import JetBrains Settings": "Import JetBrains Settings", "These settings will be imported:": "These settings will be imported:", @@ -184,14 +193,7 @@ "JDK used by Run/Debug for this module. Maven and Gradle manage their own compiler JDK.": "JDK used by Run/Debug for this module. Maven and Gradle manage their own compiler JDK.", "Remove Module Overrides": "Remove Module Overrides", "Java compiler output must be inside its module: {output}": "Java compiler output must be inside its module: {output}", - "Install 'openidea' Command": "Install 'openidea' Command", - "Uninstall 'openidea' Command": "Uninstall 'openidea' Command", - "The openidea command is installed": "The openidea command is installed", - "The openidea command is uninstalled": "The openidea command is uninstalled", - "The openidea command is not installed": "The openidea command is not installed", - "Cannot install the openidea command": "Cannot install the openidea command", - "Cannot uninstall the openidea command": "Cannot uninstall the openidea command", - "Open a new terminal to use the openidea command.": "Open a new terminal to use the openidea command.", - "Add {path} to the beginning of PATH, then open a new terminal.": "Add {path} to the beginning of PATH, then open a new terminal.", - "Cannot choose a directory": "Cannot choose a directory" + "Cannot choose a directory": "Cannot choose a directory", + "Open in HTTP Client": "Open in HTTP Client", + "Cannot generate the HTTP request": "Cannot generate the HTTP request" } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index aa54d4672..cd2b70950 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,4 +1,13 @@ { + "Install 'openidea' Command": "安装 'openidea' 命令", + "Uninstall 'openidea' Command": "卸载 'openidea' 命令", + "The openidea command is installed": "openidea 命令已安装", + "The openidea command is uninstalled": "openidea 命令已卸载", + "The openidea command is not installed": "openidea 命令未安装", + "Cannot install the openidea command": "无法安装 openidea 命令", + "Cannot uninstall the openidea command": "无法卸载 openidea 命令", + "Open a new terminal to use the openidea command.": "请打开新终端以使用 openidea 命令。", + "Add {path} to the beginning of PATH, then open a new terminal.": "请将 {path} 添加到 PATH 开头,然后打开新终端。", "Cannot copy reference": "无法复制引用", "Group actions": "分组操作", "New Project Group": "新建项目组", @@ -6563,14 +6572,6 @@ "JDK used by Run/Debug for this module. Maven and Gradle manage their own compiler JDK.": "此模块运行和调试使用的 JDK。编译 JDK 由 Maven 和 Gradle 管理。", "Remove Module Overrides": "移除模块覆盖设置", "Java compiler output must be inside its module: {output}": "Java 编译输出必须位于所属模块目录内:{output}", - "Install 'openidea' Command": "安装 'openidea' 命令", - "Uninstall 'openidea' Command": "卸载 'openidea' 命令", - "The openidea command is installed": "openidea 命令已安装", - "The openidea command is uninstalled": "openidea 命令已卸载", - "The openidea command is not installed": "openidea 命令未安装", - "Cannot install the openidea command": "无法安装 openidea 命令", - "Cannot uninstall the openidea command": "无法卸载 openidea 命令", - "Open a new terminal to use the openidea command.": "请打开新终端以使用 openidea 命令。", - "Add {path} to the beginning of PATH, then open a new terminal.": "请将 {path} 添加到 PATH 开头,然后打开新终端。", - "Cannot choose a directory": "无法选择目录" + "Cannot choose a directory": "无法选择目录", + "Open in HTTP Client": "在 HTTP 客户端中打开" } diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index d2275d351..141a6bfa9 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -1474,6 +1474,9 @@ export function runAnythingProviderList(): RunAnythingProvider[] { export interface EndpointEntry { /** `GET`, `POST`, … */ method: string; + framework?: string; + handler?: string; + pluginId?: string; /** Class-level and method-level mapping joined; `{var}` kept. */ url: string; controller: string; @@ -1490,17 +1493,33 @@ export interface EndpointProvider { } const endpointProviders = new Map(); +const endpointListeners = new Set<() => void>(); +export function onEndpointsChanged(listener: () => void): () => void { + endpointListeners.add(listener); + return () => { + endpointListeners.delete(listener); + }; +} +/** Endpoints a provider reports may have changed: `onEndpointsChanged` listeners ask again. */ +export function endpointsChanged() { + for (const listener of endpointListeners) listener(); +} export function registerEndpointProvider(p: EndpointProvider): () => void { endpointProviders.set(p.id, p); + endpointsChanged(); return () => { - if (endpointProviders.get(p.id) === p) endpointProviders.delete(p.id); + if (endpointProviders.get(p.id) === p) { + endpointProviders.delete(p.id); + endpointsChanged(); + } }; } export async function searchEndpoints(root: string | null): Promise { if (!root) return []; - return (await Promise.all([...endpointProviders.values()].map((p) => p.endpoints(root)))).flat(); + const results = await Promise.allSettled([...endpointProviders.values()].map(async (p) => p.endpoints(root))); + return results.flatMap((result) => (result.status === "fulfilled" ? result.value : [])); } // --------------------------------------------------------------------------- diff --git a/wit/endpoints.wit b/wit/endpoints.wit new file mode 100644 index 000000000..95302fcb0 --- /dev/null +++ b/wit/endpoints.wit @@ -0,0 +1,26 @@ +package openidea:plugin@1.3.0; + +/// HTTP endpoints found in a project (`[[contributes.endpoints]]`). +interface endpoints-provider { + use types.{error, location}; + use invocation.{invocation}; + + /// One HTTP route. + record endpoint { + /// `none` accepts any HTTP method. + method: option, + /// Path parameters use braces, e.g. `/photos/{id}`. + path: string, + /// Shown next to the route, e.g. `Rails`. + framework: string, + /// Display name, e.g. `PhotosController#show`. + handler: option, + /// Where the route is declared, inside the project. + location: location, + } + + /// The project's routes, complete. Called when the IDE first needs them and + /// again after a file matching `files` changes; cached in between. Runs in + /// the background: it can't ask the user. + endpoints: func(ctx: borrow) -> result, error>; +} diff --git a/wit/plugin.wit b/wit/plugin.wit index ad07bbacc..311c4cef6 100644 --- a/wit/plugin.wit +++ b/wit/plugin.wit @@ -140,3 +140,13 @@ world commit-checks { world file-actions-provider { export file-actions; } + +/// A plugin with project HTTP endpoints (`[[contributes.endpoints]]`). +world plugin-endpoints { + include plugin; + export endpoints-provider; +} + +world endpoints { + export endpoints-provider; +}