From 4ef4adf9b32682412b218deb6f059c63a2f0946c Mon Sep 17 00:00:00 2001 From: arookieofc Date: Sat, 7 Nov 2026 22:48:17 +0800 Subject: [PATCH 1/2] fix(plugins): run process: programs through their PATH-found link (#2007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(plugins): run process: programs through their PATH-found link resolve_program canonicalized the found file, so a symlinked cargo (rustup, asdf, mise and other multicall shims) resolved to the shim itself: Command::new then ran rustup with cargo's arguments. The link is what dispatches on argv[0], so keep it as the pinned path. The grant still pins the target's content: hashing through the link reads the file it points to, and relinking or replacing either still fails the approved-file check. Fixes #2001 * fix(plugins): canonicalize the dir, not the program link CI showed the middle ground is needed: tests (and the pins file) compare against canonicalized paths — /var → /private/var on macOS — but the file name itself must stay the PATH-found link so multicall shims dispatch on argv[0]. Canonicalize the parent only. * fix(ide-plugin): set file_action in windows-only test action The CodeAction struct gained a file_action field but this windows-only test fixture was not updated, breaking the Windows build (E0063). A plain action is not a file action. --- crates/ide-extension/src/ledger.rs | 55 ++++++++++++++++++++++++++++-- crates/ide-plugin/src/runtime.rs | 1 + 2 files changed, 53 insertions(+), 3 deletions(-) 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-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index fc13fe8e9..b3e26c02c 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -1650,6 +1650,7 @@ mod tests { operations: Vec::new(), open: None, data: None, + file_action: false, }; assert_eq!(code_action_to_wit(&action).edits[0].path, expected); } From 668206ddfd4d3247d91e9f8b0e58887b5fd0f284 Mon Sep 17 00:00:00 2001 From: Arnaud Ritti <77437157+arnaud-ritti@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:59:21 +0100 Subject: [PATCH 2/2] feat(plugins): share project HTTP endpoints with the HTTP client (#1991) Adds the API 1.3 `endpoints-provider` export and `[[contributes.endpoints]]` (file globs, ^1.3, `project.read`). A plugin's routes join the Spring ones in Search Everywhere, `.http` URL completion and the declaration's Open in HTTP Client gutter action. The UI asks `plugin_endpoints` on demand; the host runs the plugin on a blocking thread, validates the whole list (bounds, project-relative locations, UTF-16 ranges) and caches it per project until a file matching the globs changes, then emits `EndpointsChanged`. A failed or invalid run keeps the last list; disabling, crashing or closing the project withdraws it, and late results can't restore it. Closes #1960. Co-authored-by: arnaud-ritti <77437157+arnaud-ritti@users.noreply.github.com> --- crates/ide-host/src/plugins/endpoints.rs | 318 +++++++++++++++++ crates/ide-host/src/plugins/index_ops.rs | 11 + crates/ide-host/src/plugins/mod.rs | 7 + crates/ide-host/src/plugins/points.rs | 1 + crates/ide-host/src/plugins/remote_host.rs | 6 + crates/ide-host/src/plugins/types.rs | 4 + crates/ide-host/src/project.rs | 3 + crates/ide-host/tests/plugins.rs | 146 ++++++++ crates/ide-plugin/src/runtime.rs | 43 ++- crates/ide-plugin/src/types.rs | 10 + crates/ide-plugin/src/wit_host.rs | 12 + .../tests/fixtures/commit/dist/plugin.wasm | Bin 100258 -> 100258 bytes .../fixtures/commit/dist/plugin.wasm.sources | 2 +- .../tests/fixtures/endpoints/Cargo.lock | 333 ++++++++++++++++++ .../tests/fixtures/endpoints/Cargo.toml | 20 ++ .../tests/fixtures/endpoints/dist/plugin.wasm | Bin 0 -> 81461 bytes .../endpoints/dist/plugin.wasm.sources | 1 + .../tests/fixtures/endpoints/plugin.toml | 13 + .../tests/fixtures/endpoints/src/lib.rs | 32 ++ .../tests/fixtures/hello/dist/plugin.wasm | Bin 143251 -> 143251 bytes .../fixtures/hello/dist/plugin.wasm.sources | 2 +- .../tests/fixtures/hover/dist/plugin.wasm | Bin 89604 -> 89604 bytes .../fixtures/hover/dist/plugin.wasm.sources | 2 +- .../tests/fixtures/indexer/dist/plugin.wasm | Bin 83031 -> 83031 bytes .../fixtures/indexer/dist/plugin.wasm.sources | 2 +- .../fixtures/inlay-hints/dist/plugin.wasm | Bin 91543 -> 91551 bytes .../inlay-hints/dist/plugin.wasm.sources | 2 +- .../tests/fixtures/jobs/dist/plugin.wasm | Bin 150169 -> 150169 bytes .../fixtures/jobs/dist/plugin.wasm.sources | 2 +- .../misbehave/dist/plugin.wasm.sources | 2 +- .../fixtures/semantic-tokens/dist/plugin.wasm | Bin 91827 -> 91835 bytes .../semantic-tokens/dist/plugin.wasm.sources | 2 +- .../tests/fixtures/storage/dist/plugin.wasm | Bin 73966 -> 73966 bytes .../fixtures/storage/dist/plugin.wasm.sources | 2 +- .../tests/fixtures/tables/dist/plugin.wasm | Bin 115788 -> 115788 bytes .../fixtures/tables/dist/plugin.wasm.sources | 2 +- e2e/pluginEndpoints.spec.ts | 63 ++++ plugins/README.md | 33 ++ .../official/ai-completion/dist/plugin.wasm | Bin 173102 -> 173110 bytes .../ai-completion/dist/plugin.wasm.sources | 2 +- plugins/official/checkstyle/dist/plugin.wasm | Bin 169558 -> 169558 bytes .../checkstyle/dist/plugin.wasm.sources | 2 +- plugins/official/csv-editor/dist/plugin.wasm | Bin 133421 -> 133421 bytes .../csv-editor/dist/plugin.wasm.sources | 2 +- plugins/official/git-toolbox/dist/plugin.wasm | Bin 110095 -> 110095 bytes .../git-toolbox/dist/plugin.wasm.sources | 2 +- plugins/official/graphviz/dist/plugin.wasm | Bin 129129 -> 129129 bytes .../graphviz/dist/plugin.wasm.sources | 2 +- .../official/key-promoter/dist/plugin.wasm | Bin 100211 -> 100211 bytes .../key-promoter/dist/plugin.wasm.sources | 2 +- .../official/license-header/dist/plugin.wasm | Bin 152629 -> 152629 bytes .../license-header/dist/plugin.wasm.sources | 2 +- plugins/official/mybatis/dist/plugin.wasm | Bin 168370 -> 168370 bytes .../official/mybatis/dist/plugin.wasm.sources | 2 +- .../official/regex-tester/dist/plugin.wasm | Bin 931754 -> 931754 bytes .../regex-tester/dist/plugin.wasm.sources | 2 +- plugins/official/solidity/dist/plugin.wasm | Bin 108045 -> 108053 bytes .../solidity/dist/plugin.wasm.sources | 2 +- .../official/string-tools/dist/plugin.wasm | Bin 165811 -> 165811 bytes .../string-tools/dist/plugin.wasm.sources | 2 +- sdk/rust/openidea-plugin/src/endpoints.rs | 19 + sdk/rust/openidea-plugin/src/lib.rs | 58 +++ sdk/rust/openidea-plugin/wit/endpoints.wit | 26 ++ sdk/rust/openidea-plugin/wit/plugin.wit | 10 + src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/plugin.rs | 10 + src/bindings.ts | 19 + src/editor/core/backend.ts | 2 + src/features/http-client/endpoints.test.ts | 73 ++++ src/features/http-client/endpoints.ts | 163 +++++++++ src/features/http-client/generatedRequests.ts | 60 ++++ src/features/http-client/index.ts | 12 + src/features/http-client/spring/editor.ts | 69 +--- .../plugins/__tests__/plugins.test.tsx | 41 ++- src/features/plugins/backend.ts | 7 +- src/features/plugins/bridge.ts | 5 +- src/features/plugins/points.tsx | 38 ++ src/features/search/__tests__/routes.test.ts | 7 + .../search/everywhere/SearchEverywhere.tsx | 12 +- src/features/search/everywhere/controller.ts | 4 + src/features/search/everywhere/routes.ts | 6 +- src/platform/locales/text/en.json | 4 +- src/platform/locales/text/zh.json | 3 +- src/workbench/contributions.ts | 23 +- wit/endpoints.wit | 26 ++ wit/plugin.wit | 10 + 86 files changed, 1709 insertions(+), 97 deletions(-) create mode 100644 crates/ide-host/src/plugins/endpoints.rs create mode 100644 crates/ide-plugin/tests/fixtures/endpoints/Cargo.lock create mode 100644 crates/ide-plugin/tests/fixtures/endpoints/Cargo.toml create mode 100644 crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm create mode 100644 crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources create mode 100644 crates/ide-plugin/tests/fixtures/endpoints/plugin.toml create mode 100644 crates/ide-plugin/tests/fixtures/endpoints/src/lib.rs create mode 100644 e2e/pluginEndpoints.spec.ts create mode 100644 sdk/rust/openidea-plugin/src/endpoints.rs create mode 100644 sdk/rust/openidea-plugin/wit/endpoints.wit create mode 100644 src/features/http-client/endpoints.test.ts create mode 100644 src/features/http-client/endpoints.ts create mode 100644 src/features/http-client/generatedRequests.ts create mode 100644 wit/endpoints.wit 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 1e0a4b5d60fa063aaf85a3c66ac93c7b0d002b71..610b9b9e781e898cd92fc232e87d41676d1c7d2b 100644 GIT binary patch delta 152 zcmZ3~&$g(aZNr8f#wVLM=5%v1ZkcRb7Qnb<@`c%wo0pgUU}rotc|n~ESip9(QvG}( z#y^`6O+3NL*aH#Yd|`GoBV*6zkh%L98MkZ}p6|uTcxH3lf?1mto0%9Glo=Qp&aw+I ogaGM6AYT!QUp!qhgmL@ygb+rS=@&v6J6M783?=5%v1ZkTLa7Qnb*@`c%wo0pgUU}rorc|n~ESip9(QvG}( z#vhvxO+3NL*Z~pWd|`GoBV)(rkh%L988>Vep6|uTcw%$hf?1mt}8KgmL@yDoIqg)pfp3l_QX&|MNCaw 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 0000000000000000000000000000000000000000..a85348d92e748d32062705c1fdc959996d231e78 GIT binary patch literal 81461 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 literal 0 HcmV?d00001 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 015e757dca13e4976c1e46c9745c9a193fa7131d..37482b7f6909734c0c802dbd345841c60515e8aa 100644 GIT binary patch delta 176 zcmbPypJVcUjtzOY89O%T-*#qVoH2RxU3112n_u63&c(Q7^W_)II2pf8uKEMq#CCK1Zv#=rJ=-S;FrMIKI&*jOyMq#CCK1Zv#=r9or`eFrMIKI&pXMytZr)=)^ z=;ma6GFdVxfbq)YgHe*3n}dF^Gya)e6Xn8qW%I$PJ3@?WHqXmF!O6G>A_i1syZL3g z8zbYM%?6eG7#W{zeqZIq$oOZoRZZF^#bzc324w~YhU4r43?V?e5Xe_V;ulXpsKtZCv5KZ z=;ma6Fj+DvfbqiQgHe*3n}dF^Gya%d6Xn8qVe`SLJ3@>rHqXmF!O6G-A_i1syZL3g z8zbY6%?6eG7#SaIeqZIq$oONkRZZF^#dszL24w~YhU4r43?V?;7syvc;`>iOsKkmQIxSw4*)!OGfe;h delta 152 zcmccK!Fs)ebwf@#N+1TcP>yf9O8b8_MjcE%Z#V=`SBKWtu@c}Ixx z#OAu16P!#pR!)x1bOx)k-F&jmjgj%jW`T}bWjLMQA|)!@t~(gj&Ty}oW!E?lt=pRY@*RWzwh4fe)rq%efJ${w8?I? zDa*BmJv7hOPrCKl%{orDmD42OGRZ?`*=5#On&(nxRAZjBo24C7%B;9+R^K^l@+D_0 z?Pf>a=Ed*KasXT0M^`zd`oJODH7gFFfPOS*v;YNUe}F5pB;W(cAvS@YkVD@NEajn# zJPP*Z_cfBAgGJ&$uMO(x@cHFDl#!bYBcPIu4^g4bf{^9VLaIVMk-vs)9@crQPRm8R z3!G9SOcf>5wg$_2^){_m%V|1k@*;b{JqJRApq)Mloegl6c!WKGF6y{=9?vH?krhie z!xM6Ui9ghnk>P3_&j~*adh#HAHnWZJb+~E_paCC_6FH5JY>IG)6p|M)h9wp;?Mc%y=Hz8<0@g8b6}LF)I&_v-nYg#-tNDmMLiT)H608AdTExGf~#n#Dbz$ zd!4?vb~ivL&0eSNBM=!E7mbn9;|R2pvGEDRI*jQ$W^Tuhax&3Y6hy(Cc{izzcY$tl zFMd7tA|N3OX-EjiQk>U6{%@VcdBeUwwNq%(J zzj-7NcgeSjN-`&LJhYRf#3jc0X&moOnvX|yKj}RjPhK{KF55B#&zb7B#$uUd$hKfS z?#OL;_9>)ddotXm-aGaSu#YtC3WBFpv3m&MWK4=LwkjrN!{BGHc2@I@{GQ^^Yi|?Z zR2L>fQ+=2?WD+f@BAfk|8rkR8*gX^2a?Tz#3VM0ZQ7IfE;RhBY&mUNX-^QSWJETL> zZnKi#;1w#c9H=IZo;;e#kI`F~csa*UHTOQb&9s4236>2ii6*&?Y)Dt*^*xpziG+-~ z$iR&Ka+4F4jcBy_B_jm&(=xq~pJsX@PiF=rA!|5Nne`5GX;v7rI%^{Gah5;QJKN3K zl8i)U=O+hqE_xcBjo+D`Y|L4JY|6QUB)QSZ=egO)tUPDrnY>t}D*tulto%{P_4yM< zTMO~C)PYr)YO_^J>_KJ<(!01n^CN~@5~&hY65MI+i=M14n1HTx3q<6_f*>Ay;#BBo z&Ad!dN_ttZE8N8_{wn+o%@a;6MYf(`x%HfIweC@>UJy(q_WA`(%?PVIBuLUj3~&YdMQpm?11)Ext@ zZ84Q>3MTdwWzkB@6QU=v#Zz$q$>J|w__bqx{Z5vk{>e$!k`W~vkr^eEkatRMC@d|s zS4l)+?u#&_TE81jrk9RICY0Vo&OOB(Xip^}pP%wTjz0Z8(yolz_>{FG9m?5NnO;5> znOq*_WO1+Xf8qLI#YC&#sh6JkSH$DC^A*>SJ1KK|hSnhCD_P*eN)}js<}J|E&@*oA zs(o^nUGw8-nd!B&myu}(c5RytOw(g1w#MllpVU-&d7Bc$_Y#lB7N0~EP7X4Ds`Xv7 zWbUMr_Ri7$+%j~%_uNBUlYuCZ+Dk*pV}pY+q8q6@zXeC{R=XjWRI|j>suPj&8Xx4$ znr}d#KjtQv*99*Q=2>&`oz-55rnp$o(5{+GVt`Pa0nzJ#}i%5t#bytyt)w>f8R# zP(x!Lb=aW@>aMlH|Bl1o#mIAim-5LsY1BWf1bi%;pDUn)K#u}{I7pXnqen-b`M+c@ zO3bfO2c6inl7|ArF@P2(qb2av7V3{ImqRO58YT^Zox;=01xW=IkH_qVZ4I=?U R4Stg#frnPZIUo38)W2nzwfq18 delta 2451 zcmZ{lc~n$Y7{K56-Y^maf(kT>9N)}XhT$Hlgym?6OKdJ^8jR!u0_qVY%|J#)1+`qR z<-#_=Ae#$v(Lo_ZMKM7^#qDItu})%fQj3yP9O=8WiAMkYzI(s>-EX`1-IrZ&v#;K! zG{+V;(p+0VX>$9u8cvqXsXNVb$92=Z)jU6Jo=cfgxp{KOEbW+5W<`@(eRIFbhXY%= zV|LVLUTikY-fS_2E_F=vfnD^;v{e8)`pFDW0W!$WKsRJb;73TCATQWY-wis#Lo0a{ z;>)MhlV3ta;y?2?RM0`Qj`L7RuFcWFVKORIi8ixC7r`Y`5$c8fEp*+$mg_ZIF4{xj z6cS;gD4CQT;=rpmXti2S-9l65+5@za-SdLs9(^!xIzSB>Hva*%Ql|wodA_rrEDl=- zkI4Nnf2blu!c{n)5&kvk$%F9e%vR&;SYr&J1|N=N1r8A0jlWyr2u_&^(^5j4xBkl9gU5Y1C)}@QJ0{U1}@9# z!DdfP6ts|KF}{4q6;corqbf9JHH_mdew3g#=|qlY3Ti#|ip>K^C3lyPk+n9kps4L= zps%mk3eZBcS896*L`JTP#z^U_2sDrpaq$E18Pj#j+=w0JWTLGoh=MuuHc}lo0NZ;v zZZ-BIFg^-th!2Ny;=20rf9oW!2|Ie!PM}W`I)nigSByCd>Zb?UK>eD?tL>vjL4Aqx z>xS^qM7~Q@kQs@i;0{Sj3^UG;;dpz}ET|>-lRkiZWPHQ~8nJ!~KrJmyj>R&G%Z3nW zqmdi%>{CeD#uR9xV>j&-a8kD=7@ko1Ru{H1Jk=Ll6_c9Kul?1|s@usQss4P&b>f>g zfQfl&K1}R2iKaA>&3;dd>~U-Cw$W@^w@rnDUf8x@3Oh*n?ghxxyXV3V+IP<;sY~h& zR`OfCTnUx~Rj1P8{h9o5y>*Fq;P{E=-gn+IZQzuGWrI?p?z}}3(p7kU52QyTAtL}8 zl(EynF>A@-k~PSTl5xnJC0FH^7TPN% zq7d*R45>W46-_1|8G(#Hau*qJlsV8IO+r3DIt=M~?0ckLDYNk@ZALmCXIEwN@rlTk z<5A8Q_j3OiuJ@FUvFe?>>4|??9Bw;Zb{V;uGN&hK6*8`z1~}!kt~U zPfxOIe()qSy?pWla+iT!+eQP^bQy}Qak|GRRTbW2O^M-qiAQ6LPa+CuM;SlS`mR|r zcUDTf=jd@N0$p!E_0ZO2APS`Ve1G!T;Ao8KPHIlC$I<(h?#Qr8miVs9M5IHN4{~bN zx1i7K`wbXxWOY){3FcROplxHdw{`iYKJ`Dd6PJt5e2kRVux?$e!GbBA)v~T^Jjbdp zJIDI5_B`u{;XKnMUUVmt+W!gpRV{l58fr!BeSf8J{sJp}@r8%T4=E1>fERknEK@PSo}e%Zuq-G zIgNSrlO2lS_G%ma?>PQlggo{45x%pDM*Xu?z{j%jxg6hj=#t|P2RWe4=+UXr{9m#c zCFa-Y9v#!Qgog~n0e~hZqb1N`3sw6TIY2WUHjL{Hn}vSmri4J`oidmUne3UNsSgCI zpm5(HFBqoK@EoV&IIg0X6E_=W0L~TN=0S$#ULe4})!tBOgKM0p;f=8{1R&TDFb?8* MXfT}hfggtc3q}pO>;M1& 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 0b8deed22903b9452de03b8b9ba3298053647566..bf157c0429e6f47b47f8433311f850d452bc4aa9 100644 GIT binary patch delta 158 zcmbO^m2>7)&J70d8LwX?e;~C zjPr#U-)wghVLZXf^k>?1MMg$v#wpu@>YEuE|7^b^&$y3~amn_{ii}>2jC;17)&J70d882)$eBaHX?e;~C zjPr#UUu<_0VLZXf^kdp|MMg$v#tGYj>YEuEe{8=Z&$y3~al!V2j61g9Q(|pF 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 006add95d46fe831056a82f598bf9250d2ef9d64..10292aee9f3170b641d1e79db1a8ff4955517822 100644 GIT binary patch delta 2474 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 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 b732a322d2e749553cf9473a1a9dda0568782ba9..ce1898c678289095c0ebb8c3dabed906b8067e38 100644 GIT binary patch delta 145 zcmaENkmcP$mJJd%jC(dq+H`X=_Do*t8o>Bxvag@yW`4IH?2Jn$bNIP1{@Lv7cSnfv z%4V&^6P!#>R!-*ha|Wxj-CUaO#>n_&^PZf2jEp^-tMj}V8JBE6mjAj{v6+d1L79Pp l!HHFXAp}Sl0{Mza{Nl-%zAm3!`mKi*D8b;fnd|!#O#qVhG7A6z delta 149 zcmaENkmcP$mJJd%j5{_<+H`X=c1&LC8o>Btvag@yW`4IH?2HR0bNIP1{@Cp6cSnfv z!e*_+6P!#BR!-*ha|Wxj-CUaO#>n_!^PZf2jEo(dtMj}V85e9mmjAj{F`kKmL79Pp o!HHFXAp}VK0{MzaeE-RpzAm3!`>lf$D98YmWboL`_5Fz^04*3XcK`qY 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 8e4fba2ca1da6a1841a73c23f26ebf93d000de9d..265487df1dbd60f3083b9afd952049bac784acb3 100644 GIT binary patch delta 239 zcmX@p!G5NLeZ!(zj2|{Ho@L1hr0&jcXR=;!=0F2OmcYlRh6V-(ZUsgKCIuD+CgsKk zAm4EUswAfZ2T+m&D!F0u{CP4=4QDp*o%fiHan0t1OS?H4|4i0hAHeu#@_{Roo2Re; z!Ol2k@`UX!U;*3BLObRQF&^2x>C_2MrYmPAPu%Xz_-6BgE6t3IS2nv`+sDZGXEXB+ zFGj{GoBeLS+oagc#K54;z`&5oA;1s1DjZ3Y2Hq Lvt6~1QQZsx3(Z;; delta 238 zcmX@p!G5NLeZ!(zjBhqCo@L1hr0&jcXR@Ai=0F2OmcYlRh6V-(ZUsgKCIuD+CgsKk zAm6bERgzPI11QM>m0UA<{yZ6`Kc_eEo%fiHamD6^OS?H4e@xb0AHeux@_{Roo2Re; z!Ol2g@`UX!U;*3BLObRQF&@~w>C_2MrVD2#Pu%Xz_+s;cE6t3I7dE?G+sDZGV>9y& zFGj`*oBeLS+oTxJ#K54;z`&5oA;1s { + 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 edb14100e43db83ef4e7d377a66201a26f61f57a..41b13eb14bdcdb428cbe881e3e6e76da738f47d5 100644 GIT binary patch delta 2729 zcmZ`*2~d>h8UCMdSEReB7;Y+cS&~YO8iTFIbKEMIBN8pgL=(ZI6^NM}nuN3stj8o! znLxPS6;?q!0D}sGd|NJI=1+z=h9M(U>Et4bjdqeW)~P9Dwdwo+dx%pyJM%yL9^ZRB z-}`^xe|FH+IcRGA+>CG9$IPAn(cjh#DCR=N=B#yNiw6e>`gQ$Sg$!O=c-f;3)m%qB z!ng+`HRW{eanqyu=!$#sEKPavusd>z(~bZ1I7Mm3;cBKW``p zUI>vdgy2FrO5`I8utva1xeKubE%MkxID*ha%huwj3>j;21w}MB9@%K3L-F_}n&dz{ zh6Ki`ED0+_WjVc*1PlE=30E;F-%m!Hz#!$XM;5OiT@Ncd=-=zH12-ruh1cJpsucWA z*qt#q?$x5^>RKwdFJd2TTK`RrXwFJ6{lkz#%LrRD`t$-qX0xnBD3t(nzxfUs5 zni9jY?24^b25m*S*1^0i3}RT`&cd-t+^BIIa&VIZw_^ntp0XXgV+Qq#=PQagniV#; z&Z1Wcn<_uqj^luX>^rf@G_JQ{fvrgP?nDuAQzrZt_Q}#+-Gw|(_p2OK;1l|D4))+% zT9=E+S$Fjg0_h-^ub3@X?%7x= z{a-->Gb>YH#cuAEdS1gujLYfyyy?78Prr_X7?Yp9j)g|%e?|dMRhi5#K!~;-uNE@V zBh+4qSAr__(V0NBt&*18kr?IB4OT;}Fj{#qVugRAcQ0%Xs&U$UkVufNgcwMYUo0EFVIl4+;lw%h|as^Ttr~;({s=Jjb7R@cLlGCa5$4V?_ z7^vjeQKf%XGJU+L3bPqC~O326+$4!qCsv4h7=rG&$%3->Pcw@$GblD*~IRJwPV_fe(18vLB$of^z#=&3>6 zW4?CdeePbBf3z_GW_S9D6s)S{gGZ&KwFqPAtYz|5`dck53??U!f|Is5k>G2i)evnI ztG#WIbmC2RS#<<|;04`B_}o)z)=|ujbC+-NTP!*ZX0h7+^RDyloZtOQRmwk#rHj06 z2U?A6O)-qq`Pt_8n6XX4QR55$K>km=^&=*@ z=l@f%u1BYUqhI&;jH;lYokIl8?#2wx#nGeai(VA)|Li`UlY%~-leRvclLZ&`ZQOAY zSN!3UO9s&ezFcm7fO)1d(oRY~^=``8ONcbGe$5b`=g*h6AtZ(hyF=c&hObQ8pU=NX z?-V|xkKE=S9hCS3&hQ0)`v>eY@EQH=9looYs8j=&4v{X0{@rlQb5{mfjGlvlKaTF; zH-bMC=G?=uhT}h?J&?a(S^~uqzOWmw%@ja~{Od!a+Q7N#jZjg@_w~I{(Z3e0)VD>< zk3Jw2B}P${yQV-TjqxRxqbJCFWWY1RY@nbl(eo45D{NO>awJR40pFi@vqdBF<%we$%yzjq(vl+MoFZ~w>n zAK&@!Ik#iLbZ)@p{@jckZKLLnz?iY>e#Km<*qk*UZ1Cbhf1j=&DVHJ33NL%LVVdip zSGeWHC`~zCdyIQEA7Aq+<3OcH>2`V0ZojOjy`ew9(x2nX z+pmPmS3+?y0>$#lg;*<4MR|*`6ixEPB2>*nJ1t*_#~Cu$;Tr5TF9F-pKt~erW4L60 z0)_-`ks}!^MM)XGp9~BABN^AxCqGO&4Fx0l63emdimge`+J*?NgZbGQz<|7)jT4i&QDY8rF;2ldu#yW;-GM!^eR{am7ngwNx(t&U07_orMF>$%`SU(p%@sKi9d&ZvUJyXBcId#Bp2lvra$Il zA2Yr_4^gx4=p6*pAueArTLLUoEmOVC+#!1&_QOqg^SD?yZOg|tF19Bh-~4am)blb* zCReYLvRuAU5aCZ z*>;p6G~DUZ#w<=KR)f#QYP2~?E#v4aeNl$p3@PPEWuS7D2C1G_rdTw$cqgY*>9!{MdoJ=1ts=yqEyb7#lXsW;#216wlGc2v-_f4h3N<@cw0&2{d5~jgBA*#Gk zi6F4gB|&Vc3FK4zu^Vl$N1b+Y4&l%^Yg9q?_9vcN>w^=9LpB_ z+77lF*~V{`F>RW!aGN*i)FmR=*Gqs+rLp6DMmcGE9U?h>Tpe7#eKcCkypO|m$YjIQ zF5NA|g=ZJ~)1>*Sni!n(n)73Pr;|$UF0A4Rw_SMeVQ3SFE;)e~HY}^xiQ7|;H&`00 z$8!wPCo$yT#vrTFVn}_Mj0_EUmz|C@;2DPQ2KYfK1G0aj{lRj!kny`kY!6xK}_FJ3OUOh^X)|8srQ!^(F#O?+9{s@? zs4R7Tpx5#B2YMYZx8O%mrK<(e+RXNzMJ&UYXZ012ox|^08ab!G2;t{-<=Pu3?(z_S9ER{}O z&`TbONS@J)JFn}Oa@74(Dih@?5)n8CR?dNh5}g984)y;tX?pjYRlwO8k4;U#?=cV5D^ zK)B`70d#>cm)jpDu4ZK1vwfopV>c&b&vaWm#sJ1Y(=Q}5N^W0n$M}Pt zamn-rZj3GvG287*?u_$=7_V$U6v=pklj+Hi=?mQ$of-dZ2Wn_$WPGwcB$aU=BV*5Y z;dDkXM#d%E<1!d0Jy2|BVqj2aU|_h#CBP5@qzi$3MI?Ulc0o_3>5S7IyqH*~ukd18 QzzS5raAtd;HxsKj0HLQhfdBvi delta 176 zcmcb%hU?lIu7)j)EXIsmwzC>Du4ZK1v3;WnV>c&b$8=je#sJ12(=Q}5N^W0n$M}Pt zal!NjZj3GvG287*?u_$=7%yx;6v=pklj*^a=?mQ$of&^@2Wn_$WPGqaB$aU=BV)&Q z;dDkXM#cr(<1!d0Jy48iVqj2aU|_h#CBP5@q5S9Ycrh*D1d1~N OcuW=T;#j-ZTZH@Cj} z!Opm3a@{8vh=}c`&+~;CuWVny%6Nj4>B;%Y^`D#>|7-`UZDwSAvfW6KaUUaN&-RZ( zj9!e4OSapHFjj0=Y-VC$P-b9Yu;&zD2m#WCK)xapzj*r9(~Qrj2b^JKnZD-?;|f-w NJcG~n+_Q`atO0ZgKJfqm delta 172 zcmZ2GiDT^~jt#$WF|OJC=T;#j-ZTZH@Cj} z!Opl~a@{8vh=}c`&+~;CFKl1H%6Nj4>B0HQ^`D#>e{2V;ZDwSAu-!Nx>zye5>}uT!;L#=l}o! delta 158 zcmeCb!`6R?ZG(IjW6x%VDp^Lx37hSzmvJ&anEbRQfbqiQ-d@Sg{;faQ8GlUn=yhSd zu(`K4SCnzZ=HH7>a5Clv1bMLx+jEoO9`)u%HWc;yt`o``9it$Vg v49W})44E7P3?V?;7syvc;`>i`E@HesU9OmM0VhzH0VvI|VEf@>MjtBxiB2|9 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 4ed139ef8608d5364fbefb29255e59a763113105..29c352cd3a5498d280d34b71b79c5e83403ea01d 100644 GIT binary patch delta 158 zcmaF)f&JwN_6;vyF}7@e`Kp_f@yg`VF9D24Cd>Yn+lv4dyh2ZK1RkX+r4EOy%-taY@a5_C^uKJnTdfx ynSp^}JG%fw2#_uW@)eQz#oMp$W<1U~-C!>x%k(*W89P{k3K>>+WN;u>t_iW;!?k delta 157 zcmaF)f&JwN_6;vyF*a;|`Kp_f@xtWNF9D1PCd>Yn+lv0dyh2ZK1Rk1+r4EOy%-r^Y@a5_C^uIzo{51$ vnSp^}JG%fw2$1##@)eQz{@btaW<1U~eZgMF4o;vr15lnpV7u-BY#{v-#1g|7#VSnHU(985kHA mu?sMS0O>*?UlEC4Je?_$asTwak&Hd8Kq&^3?LkqDin;)V2r@kY delta 148 zcmey|$M(68ZG%ZUBY#{vH8)e|7#WFnHU(985kHA pu?sMS0BK(!UlED#Kbv5vF{uCm 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 266959871fddb47d5b8287a310141e76889db12c..2397657683d747789b0fa8ae44239d5fb7df6532 100644 GIT binary patch delta 168 zcmdnGf^+K%&W0_F{EUoiwhJ&aF5_hUGkrT3V*ulu>G8shlG_!z8Go=dPMIzx%;>`S zW;;+USCsL{_Olv{Cpej|+?Xyc%;*eJYr9>-n9+@q@yhmi6UKdvjDNPvnK61XGEUi^ zY0j8_O0k)VfkBypfng$t07D3nE(G!wk@&^aKQb~MpB})(#4>#c6H^B(P@ZAWc28!e GHvs_IPA<9t delta 167 zcmdnGf^+K%&W0_F{EUn%whJ&aF5_hUF?~B1V*ule>G8shlG_!z8Go=dPM9tw%;>`S zVmnYQSCsL<_Olv{Cpeid+?Xyc%;*eJYr9>-n9+@q@xu0a6UKdvj6b%^nK61XGEUf@ zY0j8_N->^^fkBypfng$t07D3n_6714k@)`8KQb~MpT38Qse=T*sTCIG6pa%*k0txD9FKhV!A>RqYIFGGLW%XjPb|z@C?QioQxe% z;psqO+wF^r7~L2dJGQ?pX57cfxMBN>QbsRE#uM8=moa)?QH*C|U{GdYV6f&AUF7M2#)7Pc+yT1AW>wrdx$J2NpgES+9Z!fwVmVf*qD_HIt5 zo@LVmxHzP@``58g;AFfr{Xsi>0OOhMh8^sJ9E@M4pI^)F!gyvnkSjS|cOARR_M;Qn z9fX;dES>%_kX>TC{1W!{oQzwLm2C$q%VK2QvVF#S_I-?uceaObWcOlZ{L;Q?6FU%d z05K;Ja{)2;_C=d`nv4~jnHU(985kJmD+@4$0O>*?UlEC4-0u2}XS?e&-h9F70pEF9 Vrf>PqyMq;|h(Tt1=?`90djQ=nRTcmM delta 244 zcmZ4W-eT2zi-s1)7N!>F7M2#)7Pc+yT1AX+wrdx$J2Nr;Su(w#gx!p>XZ!LJ_HIt5 zj%CvWxHzP@``58g;AFfp{Xsi>0ON`6h8^sJ9E=~PpI^)F!gyjjkSjS|cOARR_M;Qn z9fX+{ES>%_kX>TC{1W!{oQxZgm2C$q%VK2QuzkjQ_I-?uH@1gwWcOlZ{LsE=6FU%d z05K;Ja{)2;_C=d`nv50WnHU(985kJmD+@4$0BK(!UlED#-|qU1XS?e&-h9F7JHGSo T-~`Gs0M#%^Y%l%6YibVwrqxph 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 1c1896f2bdea845edfb87059f567fc892f6770cd..4aba760b8f1e343ee3077956fcb6860477b7f6fb 100644 GIT binary patch delta 2499 zcmZuz3s4hR6y3XlfCL5{fdQ4W1V3QGj-bI0I%F*1Ur}4Dt+lOH7z#tJTB}yYio#d~ z^eGXm1%+CXkJJ?diWV5FVlx$$uY|86sGtE;MFs7Bn@vQ^WaixW?!E7R&bzytJsRl& zjWlnSCFJv~E$2(OU1~07WZM{}MwD7n7Krj`p26NL?#-!lOp8GYbaO)745Aw=6Qqgi zoK%Cawcsm8DH4qt4&uUwTJ;SER~7PeHN4<)c!nCD>H(kr@EleA2G~gOloL zkRT&J$|U|+suQr$Eu<*z3JQMb(X;`)h$1}<%1LUvKeQ8VdW3t=yG+YLY0z;(VW8CW z-(;i%l4*O~*Q!RSodab%Kj(NHKsjIed-OngR%eD|o*S72ipZ*yYyC7ry>>+cpEAyp zlW-El)he|HlYwd(C*>?S3!{nHX05~fipdH??9BQc(#V|b$N%r1%*lx#P&<_$mD_{l z5!cgBmId_!^EXs^fnd)sI&IBDF&UiiMz)_BhnLTvSuX5P;kqP$5l)CeKMuuIUUfDI za9A4D5!f`DT^Is2{E@<0fOph>E(VJE$n%L39Du%y3ve^d{g-m)?05fu{I1FAo8raM~U%Z8M(xGKyIegkdwDY zU>nzOg(CV@%thQ;k!Wp*Avfo!3s<=W)jw1!5KmQ3Ky0lHLG-I~MO;!f4KcZD8DdwJ zFXG7RV8qC37e`|$W^$`Q8xSke^J&%iJnBhK&0<8h_A+8wZ8+li+o?{*GnSZLGSgVA zQcoVP8`Ms~dxOvd+ie)K{R%RYP=jj$mxfOS{6^7_&ekH$y)YDSDEj^}#^Z zS`M*Cy8utktPjF}{EW?!kVblP zb>k$&w8r5g2b_h1wUZf~CNND z8tb>4Z)#F3LI{hGlPug#e2-%6W6e|XcvEx2o2XV8byG_=sz=|U9WT4H0rBRYiHNeh z|Cm^}mYZ>sxo@(NuDh}5va$68#LU)O#2xo&fV20uAdYYIM*OO+9x1hTJgZwWU%jV*z({zJg0kb4e|U#8uj7BBE%CNG;>`C&Aha8 zJm~p^P8T{!*^k^2%O9cZzQ=#z^77+pmLfwy^13}xRom@C7V6?n8Q&_UTStf6vx_s0 zti4>?zoZpiU!p@vR~uqxH!Z2Q`xjR+1*2dsZE+ItEASd-_v?^6p#@)lLg#0?elp@# zeHrM}XZ#CzvooJ=9k`mhr=I9){!C#qzeUgdXNhQTeD)cle-93QmsqxAydq^2>82K;b-VZ&M0X=*b54Kp)J-p+~S+%9d0J} zQEm?PwHYaAk#c8Z(`zeabRpMYZN=5R*Di<=uPG&|uaS}i!vXIwKLkR}L8~q_hh+dA zDtim?vWjV{rjSfLICYZ(2blWUB*Y5bOvU>Nq#JYlSk>3oe>Nk;=;lGEr3d zKn`@MV|*c00==pZ1vr65^`0LJtssVKh0u0a#Ltv9XC`*1Q7`a=RV>7-&rX7JDr5bj zUN$7dPz!LZ-FbRQCcC2EFavT&!9mriDDZaM!7_|Hga2eK?3l$U*Wli*-+rxnT@+-Q jtA?$I_(7;=(9Sriv)99DfB<#H_ppeC!)lL>(CYOchL{46 delta 2495 zcmZuz4^)k382_H{c5kg)ryRFVnby~h$jVl6^CwRCSpWP>*o@8EX5<*hY|JoZqK;AV z(v@XJ%}Rg77dM3J+#z!&+)64HMYvIEgwme(yWhQHozD3^@B2Q#_xbbtp6|Quxh&gp zS(d-h3Uc{HR^hUZMUBObd?TaQiB>P#e9=D5H~Krpy_Ax>#SPF++HE7_9lEhL!(QmJ<38Gv5b#|-6SeriIi>e3m}m^ ziXV+QBw;ONlGKDjkjbA)umRAM3yCv8N0ui$^FBL$z&7(Ht-$`wFPM}eJI^?hExU># zpARN8S!g23d)C2G;*b;q8DxHvJFYh+C1C|ln#mAH7H9(O;%?IbY8c1RqFYFw#uHk| z4b8}*iM2xLWNeJ#Bx;5-San}Mi~RTZzQTATl0O0+zdX50f=z|&4+Y5J6MqL75LZKk z1e^3x$MFYKoq+9XBt>Z#(C}Lhr1irhs`SNhk));vKm(~wU+UiWw$e&a8_zhQFi;!# zZ!^*X#k4!*XI(DT&VjmtpL!?`;38k~XLNsg7G*8LJXf;_6p)37fAH4{_1YH+ddfK~ zPQpoyk5;WWx{OlGIT>fkS(;qLE_)?DubAw`h^^UQKnj_f^Z5VWlc~Ae`#qh&d*pSY zc*OOXlU075p!`)$eh?`5S;uTxC?o@pyOB)=K2S{fg89P!2(C+x&w?DI{5TX7c+H7m zfE@mob}2SZrksobJ-`3tWWZ;XD{EO0L8FZ4}WuGCo zmCb=pKJ?ONnM1jdZ8+*BER@zkwjfqw;M1z`eKe5V8?zDF>hp+Y)k_dZ-Ar{d-Lb;#k}y-P zY6E$Ab170jt?@wISTh3gaE-6R+*gp9sv~VRj`*e zP*p95*i}ov4`Fq|xK6B7ApTLe6;Hfw{fz6ZTVEnt*N<>9M&o2Ewm>3h1C4*C=14?6 zy}77NZLXPBQIv7Si4niNjZ<)5z8MTb=Tv+aC!dmL@QAtAo(4`@u<4Ph0JW*ZqE2dDIINexINoB z^T;Zcvc4rnwtt0$lJb>W<%B#T1N+wX(xWz+Zu0n9`?1@`M(A{t2C*NuA>m zH*}VPAwBe8z$Y8_bVL8u)I9Y>s`Q!4?0!R@@Mj6=u7CD9Vn7!a$?`c?#QQ&wreYMn zpyloB8B8o*X5lLDC4E6#U(!>T?tSK%ZwT;rx60qxjgnE*ey|rb3ZX65G~D8sAfz9OM9s#sl6!4p22|6@OT77NkOkYDTR3e zO&WzIcv*MX)2|hNeYR^STSBY^&#zg-GRV*d$|1xNQnYby&}5BvnHQXuLV?E17qZY) z`a&)=X=D5#N&=l4g9@BLr+LpGjb1Q=^bNu<#0BwZ%7(KLyVGfB_`^aLVzej5LOHdu z0Z=C&m?B)@fMe}X(@S#sIqm9D$n$_ijmL8Ec8g>g#+||cWGwBO*=VQX-mT9*T)T2P jWJ@*fs~}E_K1QJ}SOvoX#%ODQgjp=?)_%AKn!Wx5DTMpr 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 15934b2328595c09b5cc2a0c213ff8a7f7dd19ed..d4921ebbea42bb542c057e98feb49acddfc57c70 100644 GIT binary patch delta 207 zcmdno&$YRqt6>YHg9PJ*?T!+R&PFU z6P!$6CQpBF%;*f2oDP&!*`5~2=*GzSW&7G7#(j*8bGGM%FnTdEZrQ#)l#%YHg9KyGc1H{iIoB=4$5V3r7IFkVY_xV14 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 9b9173e6f..15bb3d3d4 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -505,6 +505,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 f15f5c5fb..4abe6b450 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1139,6 +1139,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"), @@ -5941,12 +5942,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 0ae97e2cd..c2fbc3ede 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -184,5 +184,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}", - "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 1d9f14871..dcb8f27c4 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -6563,5 +6563,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}", - "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; +}