diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index d1f395369..c1c76fdb8 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -223,6 +223,10 @@ impl Host { self.cancel_request(request); } let project = self.projects.write().remove(window)?; + if let Some(plugins) = plugins::editor_plugin_host() { + plugins.invalidate_client_navigation(&project.root, Some(window), None); + plugins.set_active_file(window, None); + } if !project.light { self.environment.project_closed(); } diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index a4eb79577..1f802df80 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -204,6 +204,17 @@ pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos) } pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult> { + if kind == GotoKind::Definition + && let Some(plugins) = crate::plugins::editor_plugin_host() + { + let project = host.project(window)?; + let (path, version) = project.documents.identity(doc)?; + if let Some(found) = plugins.navigation_at(&project.root, &path, version, pos, Some(window), |p| { + project.documents.by_path(p).and_then(|id| project.documents.version(id).ok()) + }) { + return Ok(found.targets); + } + } let result = host.project(window)?.lsp.goto(doc, pos, kind).await; if kind != GotoKind::Definition { return result; diff --git a/crates/ide-host/src/plugins/index_ops.rs b/crates/ide-host/src/plugins/index_ops.rs new file mode 100644 index 000000000..ebc1b5d9b --- /dev/null +++ b/crates/ide-host/src/plugins/index_ops.rs @@ -0,0 +1,177 @@ +use super::PluginHost; +use ide_core::{IdeError, IdeResult}; +use ide_plugin::Invocation; +use std::{path::PathBuf, sync::Arc}; +#[derive(serde::Serialize, specta::Type)] +pub struct NavigationFiles { + pub paths: Vec, + pub indexing: bool, + pub extensions: Vec, +} + +/// File discovery is separate from clicking: the UI schedules small background +/// batches after initial index completion and updates only changed files later. +pub async fn plugin_navigation_files( + host: &crate::Host, + plugins: Arc, + window: &str, +) -> IdeResult { + let project = host.project(window)?; + blocking(move || { + let extensions = plugins.navigation_extensions(&project.root); + if extensions.is_empty() { + return Ok(NavigationFiles { paths: vec![], indexing: false, extensions: vec![] }); + } + let indexing = project.index.status().indexing; + let files = project.index.files_with_extensions(&extensions, 20_001); + if files.len() > 20_000 { + return Err(IdeError::unavailable("plugin navigation supports up to 20,000 matching files")); + } + Ok(NavigationFiles { + paths: files.into_iter().filter(|p| p.starts_with(&project.root)).map(Into::into).collect(), + indexing, + extensions, + }) + }) + .await +} + +pub fn plugin_navigation_invalidate( + host: &crate::Host, + plugins: Arc, + window: &str, + paths: Option>, +) -> IdeResult<()> { + let project = host.project(window)?; + plugins.invalidate_client_navigation(&project.root, Some(window), paths.as_deref()); + Ok(()) +} + +pub async fn plugin_navigation_index( + host: &crate::Host, + plugins: Arc, + window: &str, + paths: Vec, + request: ide_core::RequestId, +) -> IdeResult<()> { + if paths.len() > 16 { + return Err(IdeError::invalid("navigation batches are limited to 16 files")); + } + let project = host.project(window)?; + let cancel = host.begin_project_request(window, request); + let client = window.to_owned(); + let result = blocking(move || { + if !project.is_trusted() { + return Err(IdeError::unavailable("project is not trusted")); + } + for path in paths { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let Ok(path) = ide_plugin::resolve_in_root(&project.root, &path.to_string_lossy()) else { + continue; + }; + let extensions = plugins.navigation_extensions(&project.root); + if !path.extension().and_then(|e| e.to_str()).is_some_and(|e| extensions.iter().any(|x| x == e)) { + continue; + } + let document = project.documents.by_path(&path); + let (text, version, language) = if let Some(id) = document { + let snapshot = project.documents.text_snapshot(id)?; + if snapshot.large { + continue; + } + (snapshot.text, Some(snapshot.version), snapshot.language) + } else { + use std::io::Read; + if !std::fs::metadata(&path) + .is_ok_and(|m| m.is_file() && m.len() <= crate::plugins::navigation::MAX_DOCUMENT_BYTES as u64) + { + continue; + } + let Ok(file) = std::fs::File::open(&path) else { + continue; + }; + let mut text = String::new(); + if file + .take((crate::plugins::navigation::MAX_DOCUMENT_BYTES + 1) as u64) + .read_to_string(&mut text) + .is_err() + { + continue; + } + let language = project.languages.detect(&path, text.lines().next()); + (text, None, language) + }; + if text.len() > crate::plugins::navigation::MAX_DOCUMENT_BYTES { + continue; + } + let symbols = project + .index + .symbols_for_text(&path, &text) + .unwrap_or_else(|| project.syntax.syntax().tags(&language, &text)); + let roots = project.roots(); + let scope = roots + .roots + .iter() + .filter(|r| path.starts_with(project.root.join(&r.path))) + .max_by_key(|r| r.path.len()) + .and_then(|r| r.detected_from.as_deref()) + .unwrap_or(""); + plugins.index_navigation( + &project.root, + crate::plugins::navigation::Snapshot { path: &path, version, text: &text, symbols: &symbols, scope }, + Invocation { client: Some(client.clone()), workspace_root: Some(project.root.clone()), editor: None }, + )?; + // A document edit while WASM was executing must not publish stale ranges. + if cancel.is_cancelled() || document.is_some_and(|id| project.documents.version(id).ok() != version) { + plugins.invalidate_client_navigation(&project.root, Some(&client), Some(&[path])); + } + } + Ok(()) + }) + .await; + host.end_request(request); + result +} + +pub async fn plugin_navigation_goto( + host: &crate::Host, + plugins: Arc, + window: &str, + doc: ide_core::DocId, + pos: ide_core::Pos, +) -> IdeResult> { + let project = host.project(window)?; + let client = window.to_owned(); + blocking(move || { + let (path, version) = project.documents.identity(doc)?; + Ok(plugins.navigation_at(&project.root, &path, version, pos, Some(&client), |p| { + project.documents.by_path(p).and_then(|id| project.documents.version(id).ok()) + })) + }) + .await +} + +pub async fn plugin_navigation_markers( + host: &crate::Host, + plugins: Arc, + window: &str, + paths: Vec, +) -> IdeResult> { + if paths.len() > 64 { + return Err(IdeError::invalid("marker batches are limited to 64 files")); + } + let project = host.project(window)?; + let client = window.to_owned(); + blocking(move || { + Ok(plugins.navigation_markers(&project.root, &paths, Some(&client), |p| { + project.documents.by_path(p).and_then(|id| project.documents.version(id).ok()) + })) + }) + .await +} + +async fn blocking(f: impl FnOnce() -> IdeResult + Send + 'static) -> IdeResult { + tokio::task::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))? +} diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index 0b82a9350..36a48662d 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -20,9 +20,11 @@ mod icon_extensions; mod icon_patterns; mod icon_settings; mod icons; +pub mod index_ops; mod install; pub mod intellij; mod market; +pub mod navigation; mod points; mod providers; pub mod remote; @@ -41,8 +43,8 @@ pub use market::{ read_url as read_market_url, resolve as resolve_market_href, }; pub use points::{COMMANDS, CommandDecl, register_builtin_points}; -pub(crate) use providers::editor_providers as editor_providers_for; pub use providers::{ProviderKind, set_editor_providers}; +pub(crate) use providers::{editor_plugin_host, editor_providers as editor_providers_for}; pub use theme::{ThemeDef, ThemeKind}; pub use types::*; @@ -308,6 +310,7 @@ pub struct PluginHost { run_detectors: Arc, /// `[[contributes.providers]]` of enabled plugins. providers: Arc, + navigation: Mutex, /// Validated UI declarations of installed packages (start-up reuses them). index: Arc, ledger: Mutex, @@ -396,6 +399,7 @@ impl PluginHost { languages, run_detectors, providers, + navigation: Mutex::default(), index, plugins: RwLock::default(), state: Mutex::new(state), @@ -1881,6 +1885,7 @@ impl PluginHost { } } let Some(error) = actor.take_crash() else { return }; + self.clear_plugin_navigation(plugin.id()); if error.starts_with(CANCELLED) { // Cancelled on request: restart on next use, no backoff. let mut meta = plugin.meta.lock(); @@ -1952,6 +1957,7 @@ impl PluginHost { } fn stop(&self, plugin: &LoadedPlugin) { + self.clear_plugin_navigation(plugin.id()); if let Some(actor) = plugin.runtime.lock().take() { actor.stop(true); } @@ -1961,6 +1967,7 @@ impl PluginHost { } fn set_failed(&self, plugin: &LoadedPlugin, error: String) { + self.clear_plugin_navigation(plugin.id()); tracing::warn!(plugin = %plugin.id(), "{error}"); self.registry.dispose_plugin(plugin.id()); let mut meta = plugin.meta.lock(); @@ -1969,6 +1976,7 @@ impl PluginHost { } fn fail(&self, plugin: &LoadedPlugin, error: &str, client: Option<&str>) { + self.clear_plugin_navigation(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/navigation.rs b/crates/ide-host/src/plugins/navigation.rs new file mode 100644 index 000000000..efa8ef5db --- /dev/null +++ b/crates/ide-host/src/plugins/navigation.rs @@ -0,0 +1,462 @@ +//! API 1.x document indexers: background snapshots become project-scoped +//! associations. Navigation and gutter markers only read validated memory indexes. +use super::PluginHost; +use ide_core::{IdeError, IdeResult, Location, Pos, Range}; +use ide_extension::{Capability, Disposable, ExtensionPoint, Package, Placement, Prepared, decode}; +use ide_language::SymbolTag; +use ide_plugin::Invocation; +use serde::Deserialize; + +use std::collections::{BTreeMap, HashMap}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Weak}; + +pub const MAX_DOCUMENT_BYTES: usize = 2 << 20; +const MAX_FILES: usize = 20_000; +const MAX_EDGES: usize = 4096; + +pub struct Snapshot<'a> { + pub path: &'a Path, + pub version: Option, + pub text: &'a str, + pub symbols: &'a [SymbolTag], + pub scope: &'a str, +} + +pub use ide_plugin::indexing::*; + +struct File { + version: Option, + scope: String, + edges: Edges, +} +#[derive(Default)] +struct Graph { + instance: Weak, + files: BTreeMap, + definitions: HashMap>, + aliases: HashMap>, + edges: usize, +} +impl Graph { + fn targets(&self, key: &str) -> Vec<&Location> { + let mut keys = vec![key.to_owned()]; + let mut seen = std::collections::HashSet::new(); + let mut targets = vec![]; + while let Some(key) = keys.pop() { + if seen.len() >= 64 || !seen.insert(key.clone()) { + continue; + } + if let Some(locations) = self.definitions.get(&key) { + targets.extend(locations.iter().take(256usize.saturating_sub(targets.len()))); + continue; + } + if let Some((prefix, suffix)) = key.split_once('#') + && let Some(aliases) = self.aliases.get(&format!("{prefix}#")) + { + for (_, target) in aliases.iter().take(64) { + keys.push(format!("{target}{suffix}")); + } + } + } + targets + } + fn remove(&mut self, path: &Path) { + if let Some(file) = self.files.remove(path) { + self.edges -= file.edges.definitions.len() + + file.edges.references.len() + + file.edges.aliases.len() + + file.edges.markers.len(); + for alias in file.edges.aliases { + if let Some(entries) = self.aliases.get_mut(&alias.prefix) { + entries.retain(|(p, _)| p != path); + if entries.is_empty() { + self.aliases.remove(&alias.prefix); + } + } + } + for anchor in file.edges.definitions { + if let Some(locations) = self.definitions.get_mut(&anchor.key) { + locations.retain(|l| l.path.0.as_path() != path); + if locations.is_empty() { + self.definitions.remove(&anchor.key); + } + } + } + } + } + fn insert(&mut self, path: PathBuf, file: File) { + self.remove(&path); + self.edges += file.edges.definitions.len() + + file.edges.references.len() + + file.edges.aliases.len() + + file.edges.markers.len(); + for alias in &file.edges.aliases { + self.aliases.entry(alias.prefix.clone()).or_default().push((path.clone(), alias.target.clone())); + } + for anchor in &file.edges.definitions { + self.definitions.entry(anchor.key.clone()).or_default().push(Location { + path: path.clone().into(), + range: anchor.range, + uri: None, + }); + } + self.files.insert(path, file); + } +} + +#[derive(Default)] +pub(crate) struct NavigationIndex { + projects: HashMap<(PathBuf, Option), HashMap>, + generation: u64, + epochs: HashMap, +} + +fn contains(range: Range, pos: Pos) -> bool { + (range.start.line, range.start.col) <= (pos.line, pos.col) && (pos.line, pos.col) < (range.end.line, range.end.col) +} +fn valid_range(lines: &[usize], range: Range) -> bool { + let pos = |p: Pos| lines.get(p.line as usize).is_some_and(|&length| p.col as usize <= length); + pos(range.start) && pos(range.end) && (range.start.line, range.start.col) < (range.end.line, range.end.col) +} + +impl PluginHost { + pub(crate) fn clear_plugin_navigation(&self, id: &str) { + let mut index = self.navigation.lock(); + index.generation = index.generation.wrapping_add(1); + let epoch = index.generation; + let roots: Vec<_> = index.projects.keys().cloned().collect(); + for root in roots { + index.epochs.insert(root.0, epoch); + } + for graphs in index.projects.values_mut() { + graphs.remove(id); + } + drop(index); + self.emit(None, super::PluginEvent::DocumentIndexesChanged); + } + + /// Extensions with an active, permissioned navigation provider for this root. + pub fn navigation_extensions(&self, root: &Path) -> Vec { + if !(self.project_policy.read())(root) { + return vec![]; + } + self.plugins + .read() + .values() + .filter(|p| p.status().is_live() && p.dev_root.as_deref().is_none_or(|r| r == root)) + .filter_map(|p| declaration(p)) + .flat_map(|d| d.extensions) + .collect() + } + + /// Remove stale contributions before scheduling replacement work. `None` + /// disposes the project, including reverse-index entries, on project close. + pub fn invalidate_navigation(&self, root: &Path, paths: Option<&[PathBuf]>) { + self.invalidate_navigation_scope(root, None, paths); + } + + pub fn invalidate_client_navigation(&self, root: &Path, client: Option<&str>, paths: Option<&[PathBuf]>) { + self.invalidate_navigation_scope(root, Some(client), paths); + } + + fn invalidate_navigation_scope(&self, root: &Path, client: Option>, paths: Option<&[PathBuf]>) { + let matches = |key: &(PathBuf, Option)| key.0 == root && client.is_none_or(|c| key.1.as_deref() == c); + let mut index = self.navigation.lock(); + index.generation = index.generation.wrapping_add(1); + let epoch = index.generation; + index.epochs.insert(root.to_owned(), epoch); + if index.epochs.len() > 64 + && let Some(oldest) = index.epochs.iter().min_by_key(|(_, v)| **v).map(|(p, _)| p.clone()) + { + index.epochs.remove(&oldest); + } + match paths { + None => { + index.projects.retain(|key, _| !matches(key)); + } + Some(paths) => { + for graphs in index.projects.iter_mut().filter(|(key, _)| matches(key)).map(|(_, graphs)| graphs) { + for graph in graphs.values_mut() { + for path in paths { + graph.remove(path); + } + } + } + } + } + } + + /// Index one bounded snapshot. No directory traversal or parsing happens in + /// navigation_at. Call off the UI thread, in small cancellable batches. + pub fn index_navigation(&self, root: &Path, snapshot: Snapshot<'_>, invocation: Invocation) -> IdeResult<()> { + let Snapshot { path, version, text, symbols, scope } = snapshot; + if !(self.project_policy.read())(root) { + return Err(IdeError::unavailable("project is not trusted")); + } + if text.len() > MAX_DOCUMENT_BYTES { + return Err(IdeError::invalid("navigation document is too large")); + } + let real = ide_plugin::resolve_in_root(root, &path.to_string_lossy()).map_err(IdeError::invalid)?; + if real != path { + return Err(IdeError::invalid("navigation requires a canonical project path")); + } + let project_key = (root.to_owned(), invocation.client.clone()); + let generation = self.navigation.lock().epochs.get(root).copied(); + let extension = path.extension().and_then(|e| e.to_str()).unwrap_or_default(); + let plugins: Vec<_> = self + .plugins + .read() + .values() + .filter(|p| { + p.status().is_live() + && p.dev_root.as_deref().is_none_or(|r| r == root) + && p.meta.lock().granted.contains(&Capability::ProjectRead) + && declaration(p).is_some_and(|n| n.extensions.iter().any(|e| e == extension)) + }) + .cloned() + .collect(); + if plugins.is_empty() { + return Ok(()); + } + let lines: Vec<_> = text.split('\n').map(|l| l.encode_utf16().count()).collect(); + for plugin in plugins { + let (actor, activated) = self.ensure_active(&plugin, invocation.client.as_deref())?; + if activated { + self.changed(); + } + let (owned_path, owned_text) = (path.to_string_lossy().into_owned(), text.to_owned()); + let symbols = symbols + .iter() + .map(|s| ide_plugin::indexing::IndexSymbol { + name: s.name.clone(), + kind: serde_json::to_value(s.kind) + .ok() + .and_then(|v| v.as_str().map(str::to_owned)) + .unwrap_or_default(), + container: s.container.clone(), + range: s.range, + }) + .collect::>(); + let ctx = invocation.clone(); + let started = std::time::Instant::now(); + let result = actor.call(None, move |rt| rt.index_document(&owned_path, &owned_text, &symbols, ctx)); + let edges = self + .finish_provider_call(&plugin, &actor, started, result) + .ok_or_else(|| IdeError::unavailable("document indexer did not return a result"))?; + let output_bytes = edges + .definitions + .iter() + .chain(&edges.references) + .chain(&edges.markers) + .map(|a| a.key.len().saturating_add(32)) + .sum::() + .saturating_add(edges.aliases.iter().map(|a| a.prefix.len() + a.target.len()).sum::()); + if output_bytes > MAX_DOCUMENT_BYTES { + return Err(IdeError::invalid("document index output is too large")); + } + if edges.aliases.len() > 256 + || edges.aliases.iter().any(|a| { + !a.prefix.ends_with('#') + || !a.target.ends_with('#') + || a.prefix.len() > 1024 + || a.target.len() > 1024 + }) + || edges.definitions.len() + edges.references.len() > MAX_EDGES + || edges + .definitions + .iter() + .chain(&edges.references) + .any(|a| a.key.is_empty() || a.key.len() > 1024 || !valid_range(&lines, a.range)) + { + return Err(IdeError::invalid("invalid navigation anchors")); + } + if edges.markers.len() > MAX_EDGES + || edges.markers.iter().any(|a| a.key.is_empty() || a.key.len() > 1024 || !valid_range(&lines, a.range)) + { + return Err(IdeError::invalid("invalid navigation markers")); + } + let mut index = self.navigation.lock(); + if index.epochs.get(root).copied() != generation { + return Err(IdeError::cancelled()); + } + // Keep a bounded number of project graphs, including closed windows + // whose frontend did not get an opportunity to dispose its state. + if index.projects.len() >= 16 && !index.projects.contains_key(&project_key) { + return Err(IdeError::unavailable("navigation project limit reached")); + } + let graph = + index.projects.entry(project_key.clone()).or_default().entry(plugin.id().to_owned()).or_default(); + if !graph.instance.ptr_eq(&Arc::downgrade(&plugin)) { + *graph = Graph { instance: Arc::downgrade(&plugin), ..Default::default() }; + } + if graph.files.len() >= MAX_FILES && !graph.files.contains_key(path) { + return Err(IdeError::unavailable("navigation file limit reached")); + } + graph.remove(path); + if graph.edges + + edges.definitions.len() + + edges.references.len() + + edges.aliases.len() + + edges.markers.len() + > 200_000 + { + return Err(IdeError::unavailable("navigation association limit reached")); + } + graph.insert(path.to_owned(), File { version, scope: scope.to_owned(), edges }); + } + Ok(()) + } + + /// Resolves only current live providers. Disable/reload/trap cannot retain + /// navigable stale data, even if a background snapshot finished concurrently. + pub fn navigation_at( + &self, + root: &Path, + path: &Path, + version: u32, + pos: Pos, + client: Option<&str>, + version_of: impl Fn(&Path) -> Option, + ) -> Option { + if !(self.project_policy.read())(root) { + return None; + } + let plugins = self.plugins.read(); + let index = self.navigation.lock(); + let graphs = index.projects.get(&(root.to_owned(), client.map(str::to_owned)))?; + let mut result: Option = None; + for (id, graph) in graphs { + let Some(plugin) = plugins.get(id) else { continue }; + if !plugin.status().is_live() || !graph.instance.ptr_eq(&Arc::downgrade(plugin)) { + continue; + } + let Some(file) = graph.files.get(path) else { continue }; + if file.version != Some(version) { + continue; + } + for reference in &file.edges.references { + if !contains(reference.range, pos) { + continue; + } + let targets = graph.targets(&reference.key); + if targets.is_empty() { + continue; + } + let answer = result.get_or_insert_with(|| NavigationResult { range: reference.range, targets: vec![] }); + let local = targets.iter().any(|t| graph.files.get(&t.path.0).is_some_and(|f| f.scope == file.scope)); + for target in targets.iter().take(256) { + let Some(target_file) = graph.files.get(&target.path.0) else { + continue; + }; + if local && target_file.scope != file.scope { + continue; + } + if version_of(&target.path).is_some_and(|v| target_file.version != Some(v)) { + continue; + } + if (target.path.0.as_path() != path || !contains(target.range, pos)) + && !answer.targets.iter().any(|t| t.path == target.path && t.range == target.range) + { + answer.targets.push((*target).clone()); + } + } + } + } + result.filter(|r| !r.targets.is_empty()) + } + /// Gutter markers of visible files. Uses only existing graph entries; never executes WASM. + pub fn navigation_markers( + &self, + root: &Path, + paths: &[PathBuf], + client: Option<&str>, + version_of: impl Fn(&Path) -> Option, + ) -> Vec { + if !(self.project_policy.read())(root) { + return vec![]; + } + let plugins = self.plugins.read(); + let index = self.navigation.lock(); + let Some(graphs) = index.projects.get(&(root.to_owned(), client.map(str::to_owned))) else { + return vec![]; + }; + let mut result = vec![]; + for path in paths { + for (id, graph) in graphs { + let Some(plugin) = plugins.get(id) else { continue }; + if !plugin.status().is_live() || !graph.instance.ptr_eq(&Arc::downgrade(plugin)) { + continue; + } + let Some(file) = graph.files.get(path) else { continue }; + if version_of(path).is_some_and(|v| file.version != Some(v)) { + continue; + } + let mut markers = vec![]; + for anchor in &file.edges.markers { + let candidates = graph.targets(&anchor.key); + let local = + candidates.iter().any(|t| graph.files.get(&t.path.0).is_some_and(|f| f.scope == file.scope)); + let mut targets = vec![]; + for target in candidates { + let Some(other) = graph.files.get(&target.path.0) else { continue }; + if (local && other.scope != file.scope) + || version_of(&target.path).is_some_and(|v| other.version != Some(v)) + || (target.path.0 == *path && target.range == anchor.range) + { + continue; + } + if !targets.iter().any(|t: &Location| t.path == target.path && t.range == target.range) { + targets.push(target.clone()); + } + } + if !targets.is_empty() { + markers.push(NavigationResult { range: anchor.range, targets }); + } + } + if !markers.is_empty() { + result.push(FileMarkers { path: path.clone(), version: file.version, markers }); + } + } + } + result + } +} + +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct Decl { + extensions: Vec, +} +fn declaration(p: &super::LoadedPlugin) -> Option { + p.manifest() + .declarations("documentIndexers") + .into_iter() + .next() + .and_then(|d| decode("documentIndexers", d.clone()).ok()) +} +pub struct IndexersPoint; +impl ExtensionPoint for IndexersPoint { + fn id(&self) -> &'static str { + "documentIndexers" + } + fn placement(&self) -> Placement { + Placement::Workspace + } + fn prepare(&self, pkg: &Package, value: toml::Value) -> Result { + let d: Decl = decode(self.id(), value)?; + if pkg.manifest.wasm.is_none() + || d.extensions.is_empty() + || d.extensions.len() > 32 + || d.extensions + .iter() + .any(|e| e.is_empty() || e.len() > 32 || !e.bytes().all(|c| c.is_ascii_alphanumeric())) + { + return Err("documentIndexers requires wasm and 1-32 file extensions".into()); + } + if pkg.manifest.declarations(self.id()).len() > 1 { + return Err("declare one document indexer per plugin".into()); + } + Ok(Prepared::new(d.extensions.join(", "), || Ok(Disposable::new(|| {}))).capability(Capability::ProjectRead)) + } +} diff --git a/crates/ide-host/src/plugins/points.rs b/crates/ide-host/src/plugins/points.rs index bdf23c660..940ac19c2 100644 --- a/crates/ide-host/src/plugins/points.rs +++ b/crates/ide-host/src/plugins/points.rs @@ -56,6 +56,7 @@ pub fn register_builtin_points( registry.register(Arc::new(LanguagesPoint::new(languages.clone())))?; registry.register(Arc::new(UiPoint::new(super::icons::POINT_ID, ui.clone(), super::icons::icon_theme)))?; registry.register(Arc::new(super::run_detectors::RunDetectorsPoint::new(run_detectors.clone())))?; + 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)))?; use super::ui_points as p; diff --git a/crates/ide-host/src/plugins/providers.rs b/crates/ide-host/src/plugins/providers.rs index b37c12e15..365c27bd2 100644 --- a/crates/ide-host/src/plugins/providers.rs +++ b/crates/ide-host/src/plugins/providers.rs @@ -180,10 +180,14 @@ pub fn set_editor_providers(host: Option<&Arc>) { /// The plugin host whose providers take part, when any declares `kind`. pub(crate) fn editor_providers(kind: ProviderKind) -> Option> { - let host = EDITOR_PROVIDERS.read().as_ref()?.upgrade()?; + let host = editor_plugin_host()?; host.providers.any(kind).then_some(host) } +pub(crate) fn editor_plugin_host() -> Option> { + EDITOR_PROVIDERS.read().as_ref()?.upgrade() +} + impl PluginHost { /// Usable plugins declaring `kind` for `language`. fn provider_plugins(&self, kind: ProviderKind, language: &str) -> Vec> { diff --git a/crates/ide-host/src/plugins/remote_host.rs b/crates/ide-host/src/plugins/remote_host.rs index 7b845a84d..d84667dca 100644 --- a/crates/ide-host/src/plugins/remote_host.rs +++ b/crates/ide-host/src/plugins/remote_host.rs @@ -448,6 +448,44 @@ impl RemotePlugins { .await } }); + let h = host.clone(); + d.add("plugin_navigation_files", move |c| { + let h = h.clone(); + async move { super::index_ops::plugin_navigation_files(&c.host, alive(&h)?, &c.window).await } + }); + let h = host.clone(); + d.add("plugin_navigation_invalidate", move |c| { let h = h.clone(); async move { + super::index_ops::plugin_navigation_invalidate(&c.host, alive(&h)?, &c.window, c.arg("paths")?) + } }); + let h = host.clone(); + d.add("plugin_navigation_index", move |c| { + let h = h.clone(); + async move { + super::index_ops::plugin_navigation_index( + &c.host, + alive(&h)?, + &c.window, + c.arg("paths")?, + c.arg("request")?, + ) + .await + } + }); + let h = host.clone(); + d.add("plugin_navigation_goto", move |c| { + let h = h.clone(); + async move { + super::index_ops::plugin_navigation_goto(&c.host, alive(&h)?, &c.window, c.arg("doc")?, c.arg("pos")?) + .await + } + }); + let h = host.clone(); + d.add("plugin_navigation_markers", move |c| { + let h = h.clone(); + async move { + super::index_ops::plugin_navigation_markers(&c.host, alive(&h)?, &c.window, c.arg("paths")?).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 53c737e54..88e240c08 100644 --- a/crates/ide-host/src/plugins/types.rs +++ b/crates/ide-host/src/plugins/types.rs @@ -153,6 +153,8 @@ pub struct PluginUpdate { pub enum PluginEvent { /// Plugins, their status or their contributions changed: re-fetch `plugin_list`. CommandsChanged, + /// Cached document associations were disposed (reload, disable or failure). + DocumentIndexesChanged, /// The marketplace offers newer versions of installed plugins (announced /// once per set of versions). UpdatesAvailable { updates: Vec }, diff --git a/crates/ide-host/tests/plugin_indexing.rs b/crates/ide-host/tests/plugin_indexing.rs new file mode 100644 index 000000000..015136f91 --- /dev/null +++ b/crates/ide-host/tests/plugin_indexing.rs @@ -0,0 +1,236 @@ +//! Generic document-indexer contract through a minimal real WASM fixture. +use ide_core::{Pos, Range, SymbolKind}; +use ide_host::plugins::navigation::Snapshot; +use ide_host::plugins::{Consent, PluginHost}; +use ide_language::SymbolTag; +use ide_plugin::{Invocation, Limits}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; + +struct Fixture { + _tmp: tempfile::TempDir, + root: PathBuf, + host: PluginHost, +} +impl Fixture { + fn new() -> Self { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().canonicalize().unwrap(); + let host = PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(); + host.set_project_policy(Arc::new(|_| true)); + host.install_from_dir( + &Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/indexer"), + &Consent::all(), + ) + .unwrap(); + Self { _tmp: tmp, root, host } + } + fn index(&self, path: &str, text: &str, symbols: &[SymbolTag], version: Option, scope: &str) -> PathBuf { + let path = self.root.join(path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, text).unwrap(); + self.host + .index_navigation( + &self.root, + Snapshot { path: &path, version, text, symbols, scope }, + Invocation { workspace_root: Some(self.root.clone()), ..Default::default() }, + ) + .unwrap(); + path + } +} +fn method() -> SymbolTag { + SymbolTag { + name: "item".into(), + kind: SymbolKind::Method, + container: None, + range: Range::new(Pos::new(0, 4), Pos::new(0, 8)), + } +} +const SOURCE: &str = "def source:item\nref mapping:item\n"; +const MAPPING: &str = "def mapping:item\nref source:item\n"; +fn reference_pos() -> Pos { + Pos::new(1, 5) +} + +#[test] +fn bidirectional_navigation_versions_and_incremental_deletion() { + let f = Fixture::new(); + let source = f.index("src/source.idx", SOURCE, &[method()], Some(1), "app"); + let mapping = f.index("resources/mapping.idx", MAPPING, &[], Some(1), "app"); + let result = f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).unwrap(); + assert_eq!(result.targets[0].path.0, source); + assert_eq!(result.range.end.col - result.range.start.col, 11); + let reverse = f.host.navigation_at(&f.root, &source, 1, reference_pos(), None, |_| None).unwrap(); + assert_eq!(reverse.targets[0].path.0, mapping); + assert!(f.host.navigation_at(&f.root, &mapping, 2, reference_pos(), None, |_| None).is_none()); + assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| Some(2)).is_none()); + f.host.invalidate_navigation(&f.root, Some(&[source])); + assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none()); +} + +#[test] +fn module_candidates_reload_disable_and_project_isolation() { + let f = Fixture::new(); + let first = f.index("a/source.idx", SOURCE, &[method()], Some(1), "a"); + f.index("b/source.idx", SOURCE, &[method()], Some(1), "b"); + let mapping = f.index("a/mapping.idx", MAPPING, &[], Some(1), "a"); + let result = f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).unwrap(); + assert_eq!(result.targets.len(), 1); + assert_eq!(result.targets[0].path.0, first); + let other = f.index("c/mapping.idx", MAPPING, &[], Some(1), "c"); + assert_eq!(f.host.navigation_at(&f.root, &other, 1, reference_pos(), None, |_| None).unwrap().targets.len(), 2); + assert!( + f.host.navigation_at(&f.root.join("other-project"), &mapping, 1, reference_pos(), None, |_| None).is_none() + ); + f.host.set_enabled("test.indexer", false).unwrap(); + assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none()); + f.host.set_enabled("test.indexer", true).unwrap(); + assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none()); + f.host.reload("test.indexer").unwrap(); + assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none()); +} + +#[test] +fn warmed_clicks_never_read_or_parse_files() { + let f = Fixture::new(); + let source = f.index("source.idx", SOURCE, &[method()], Some(1), ""); + let mapping = f.index("mapping.idx", MAPPING, &[], Some(1), ""); + // Files become unreadable; the watcher invalidates separately. Resolution + // must remain purely in-memory, with no disk reads or component execution. + std::fs::remove_file(&source).unwrap(); + std::fs::remove_file(&mapping).unwrap(); + let calls = f.host.info_of("test.indexer").unwrap().stats.calls; + for _ in 0..10_000 { + assert_eq!( + f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).unwrap().targets.len(), + 1 + ); + } + assert_eq!(f.host.info_of("test.indexer").unwrap().stats.calls, calls, "warm navigation must not execute Wasm"); + f.host.invalidate_navigation(&f.root, None); + assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none()); +} + +#[test] +fn permissions_and_paths_are_checked_before_indexing() { + let f = Fixture::new(); + let external = tempfile::NamedTempFile::new().unwrap(); + assert!( + f.host + .index_navigation( + &f.root, + Snapshot { path: external.path(), version: None, text: "", symbols: &[], scope: "" }, + Invocation::default() + ) + .is_err() + ); + f.host.set_project_policy(Arc::new(|_| false)); + assert!(f.host.navigation_extensions(&f.root).is_empty()); + assert!( + f.host + .index_navigation( + &f.root, + Snapshot { path: &f.root.join("x.idx"), version: None, text: MAPPING, symbols: &[], scope: "" }, + Invocation::default() + ) + .is_err() + ); +} + +#[test] +fn syntax_symbols_aliases_and_utf16_ranges_cross_the_wasm_boundary() { + let f = Fixture::new(); + let symbol = method(); + let definition = f.index("source.idx", "def item\nsymbols parent#\n", &[symbol], Some(1), "app"); + f.index("child.idx", "alias child# parent#\n", &[], Some(1), "app"); + let mapping = f.index("mapping.idx", "ref child#item\n", &[], Some(1), "app"); + let result = f.host.navigation_at(&f.root, &mapping, 1, Pos::new(0, 6), None, |_| None).unwrap(); + assert_eq!(result.targets[0].path.0, definition); + assert_eq!(result.targets[0].range, method().range); + f.index("unicode.idx", "def 😀\n", &[], Some(1), "app"); + let unicode = f.index("unicode-ref.idx", "ref 😀\n", &[], Some(1), "app"); + let result = f.host.navigation_at(&f.root, &unicode, 1, Pos::new(0, 5), None, |_| None).unwrap(); + assert_eq!(result.range, Range::new(Pos::new(0, 4), Pos::new(0, 6))); +} + +#[test] +fn two_windows_of_same_root_keep_unsaved_snapshots_isolated() { + let f = Fixture::new(); + let source = f.index("source.idx", SOURCE, &[method()], Some(1), ""); + let mapping = f.index("mapping.idx", MAPPING, &[], Some(1), ""); + for client in ["one", "two"] { + let mut symbol = method(); + if client == "two" { + symbol.range = Range::new(Pos::new(0, 5), Pos::new(0, 9)); + } + for (path, text, symbols) in [(&source, "symbols source:\n", vec![symbol]), (&mapping, MAPPING, vec![])] { + f.host + .index_navigation( + &f.root, + Snapshot { path, version: Some(1), text, symbols: &symbols, scope: "" }, + Invocation { client: Some(client.into()), workspace_root: Some(f.root.clone()), editor: None }, + ) + .unwrap(); + } + } + assert_eq!( + f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), Some("one"), |_| None).unwrap().targets[0] + .range + .start + .col, + 4 + ); + assert_eq!( + f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), Some("two"), |_| None).unwrap().targets[0] + .range + .start + .col, + 5 + ); + f.host.invalidate_client_navigation(&f.root, Some("one"), None); + assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), Some("one"), |_| None).is_none()); + assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), Some("two"), |_| None).is_some()); +} + +#[test] +fn markers_and_disable_follow_graph_state() { + let f = Fixture::new(); + let tags = vec![]; + let source = f.index("source.idx", SOURCE, &tags, Some(1), "app"); + assert!(f.host.navigation_markers(&f.root, std::slice::from_ref(&source), None, |_| None).is_empty()); + let mapping = f.index("mapping.idx", MAPPING, &[], Some(1), "app"); + let entries = f.host.navigation_markers(&f.root, &[source.clone(), mapping.clone()], None, |_| None); + let source_markers = entries.iter().find(|e| e.path == source).unwrap(); + assert_eq!(source_markers.markers.len(), 1); + assert_eq!(source_markers.markers[0].targets[0].path.0, mapping); + let mapping_markers = entries.iter().find(|e| e.path == mapping).unwrap(); + assert_eq!(mapping_markers.markers.len(), 1); + assert_eq!(mapping_markers.markers[0].targets[0].path.0, source); + assert!(f.host.navigation_markers(&f.root, std::slice::from_ref(&source), None, |_| Some(2)).is_empty()); + f.host.invalidate_navigation(&f.root, Some(&[mapping])); + assert!(f.host.navigation_markers(&f.root, std::slice::from_ref(&source), None, |_| None).is_empty()); + f.host.set_enabled("test.indexer", false).unwrap(); + assert!(f.host.navigation_markers(&f.root, &[source], None, |_| None).is_empty()); +} + +#[test] +fn invalid_ranges_and_oversized_documents_never_publish() { + let f = Fixture::new(); + let path = f.root.join("invalid.idx"); + std::fs::write(&path, "invalid key\n").unwrap(); + for text in ["invalid key\n".to_owned(), "x".repeat(ide_host::plugins::navigation::MAX_DOCUMENT_BYTES + 1)] { + assert!( + f.host + .index_navigation( + &f.root, + Snapshot { path: &path, version: Some(1), text: &text, symbols: &[], scope: "" }, + Invocation { workspace_root: Some(f.root.clone()), ..Default::default() }, + ) + .is_err() + ); + } + assert!(f.host.navigation_markers(&f.root, &[path], None, |_| None).is_empty()); +} diff --git a/crates/ide-index/src/lib.rs b/crates/ide-index/src/lib.rs index be02babc2..b52f666e1 100644 --- a/crates/ide-index/src/lib.rs +++ b/crates/ide-index/src/lib.rs @@ -189,6 +189,43 @@ impl ProjectIndex { out } + /// Reuse indexed syntax tags only for the exact text snapshot. Unsaved or + /// not-yet-indexed contents fall back to the caller's parser. + pub fn symbols_for_text(&self, path: &Path, text: &str) -> Option> { + let hash = blake3::hash(text.as_bytes()); + let data = self.shared.data.read(); + let file = data.get(path)?; + if &file.hash != hash.as_bytes() { + return None; + } + Some( + file.symbols + .iter() + .map(|s| ide_language::SymbolTag { + name: s.name.to_string(), + kind: s.kind, + container: s.container.as_ref().map(ToString::to_string), + range: s.range, + }) + .collect(), + ) + } + + /// Bounded file inventory for background plugin indexers; honors the same + /// project/exclude scope as search and never walks the filesystem. + pub fn files_with_extensions(&self, extensions: &[String], limit: usize) -> Vec { + self.shared + .data + .read() + .files + .iter() + .flatten() + .filter(|f| f.path.extension().and_then(|e| e.to_str()).is_some_and(|e| extensions.iter().any(|x| x == e))) + .take(limit) + .map(|f| f.path.clone()) + .collect() + } + /// Fuzzy file search. IDEA-like ranking: file-name matches beat directory /// matches; `/` in the query matches path segments; recently opened files boost. pub fn find_files(&self, query: &str, limit: usize) -> Vec { diff --git a/crates/ide-index/src/tests.rs b/crates/ide-index/src/tests.rs index d8dadee2f..765f8cc17 100644 --- a/crates/ide-index/src/tests.rs +++ b/crates/ide-index/src/tests.rs @@ -632,3 +632,23 @@ fn workspace_scope_excludes_state_from_scans_updates_and_cached_results() { assert!(idx.wait_idle(WAIT)); assert!(idx.find_files("", 100).is_empty()); } + +#[test] +fn plugin_inventory_respects_excludes_and_symbols_require_matching_text() { + let java = "package demo; interface UserMapper { void find(); }"; + let f = Fixture::new(&[ + ("src/UserMapper.java", java), + ("src/mapper.xml", ""), + ("target/Generated.java", "class Generated {}"), + ("other.txt", "ignored"), + ]); + let index = f.open(); + let paths = index.files_with_extensions(&["java".into(), "xml".into()], 100); + assert_eq!(paths.len(), 2); + assert!(paths.iter().all(|p| p.starts_with(f.root.join("src")))); + assert_eq!(index.files_with_extensions(&["java".into(), "xml".into()], 1).len(), 1); + let path = f.root.join("src/UserMapper.java"); + let symbols = index.symbols_for_text(&path, java).unwrap(); + assert!(symbols.iter().any(|s| s.name == "find")); + assert!(index.symbols_for_text(&path, &java.replace("find", "renamed")).is_none()); +} diff --git a/crates/ide-plugin/src/indexing.rs b/crates/ide-plugin/src/indexing.rs new file mode 100644 index 000000000..fe0ef3e99 --- /dev/null +++ b/crates/ide-plugin/src/indexing.rs @@ -0,0 +1,53 @@ +//! Edges a `document-indexer` export returns, and what the host answers from them. +use ide_core::{Location, Range}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::path::PathBuf; + +#[derive(Clone, Debug, Deserialize, Serialize, Type)] +#[serde(deny_unknown_fields)] +pub struct Anchor { + pub key: String, + pub range: Range, +} + +#[derive(Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Alias { + pub prefix: String, + pub target: String, +} + +#[derive(Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Edges { + pub definitions: Vec, + pub references: Vec, + #[serde(default)] + pub aliases: Vec, + #[serde(default)] + pub markers: Vec, +} + +/// The resolved gutter markers of one file. +#[derive(Clone, Debug, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct FileMarkers { + pub path: PathBuf, + pub version: Option, + pub markers: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct NavigationResult { + pub range: Range, + pub targets: Vec, +} + +pub struct IndexSymbol { + pub name: String, + pub kind: String, + pub container: Option, + pub range: Range, +} diff --git a/crates/ide-plugin/src/lib.rs b/crates/ide-plugin/src/lib.rs index fd27cb249..dae692bd1 100644 --- a/crates/ide-plugin/src/lib.rs +++ b/crates/ide-plugin/src/lib.rs @@ -9,6 +9,7 @@ //! host drops it. Each active plugin's runtime lives on its own [`Actor`] thread. mod actor; +pub mod indexing; mod runtime; mod sandbox; mod types; diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index 6253c3710..ff442ab02 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -11,7 +11,7 @@ use crate::types::{ use crate::types::{ FieldValue, View, ViewBody, ViewButton, ViewEvent, ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport, }; -use crate::wit_host::{bindings, providers_bindings, syntax_bindings, views_bindings}; +use crate::wit_host::{bindings, indexing_bindings, providers_bindings, syntax_bindings, views_bindings}; use ide_core::CancellationToken; use ide_core::ipc_path::to_ipc; use ide_extension::Capability; @@ -27,6 +27,7 @@ use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView}; use bindings::openidea::plugin::events as wit_events; use bindings::openidea::plugin::types as wit_types; +use indexing_bindings::exports::openidea::plugin::document_indexer as wit_index; use providers_bindings::exports::openidea::plugin::providers as wit_prov; use syntax_bindings::exports::openidea::plugin::syntax_provider as wit_syntax; use views_bindings::exports::openidea::plugin::view_provider as wit_view; @@ -484,6 +485,7 @@ struct Exports { plugin: bindings::Plugin, syntax: Option, providers: Option, + indexer: Option, views: Option, } @@ -560,6 +562,7 @@ impl Runtime { let pre = engine.linker.instantiate_pre(component).map_err(api_hint)?; let plugin_indices = bindings::PluginIndices::new(&pre).map_err(api_hint)?; let syntax_indices = wit_syntax::GuestIndices::new(&pre).ok(); + let index_indices = wit_index::GuestIndices::new(&pre).ok(); let provider_indices = wit_prov::GuestIndices::new(&pre).ok(); let view_indices = wit_view::GuestIndices::new(&pre).ok(); let instance = pre.instantiate(&mut store).map_err(api_hint)?; @@ -572,13 +575,17 @@ impl Runtime { Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?), None => None, }; + let indexer = match index_indices { + Some(i) => Some(i.load(&mut store, &instance).map_err(api_hint)?), + None => None, + }; let views = match view_indices { Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?), None => None, }; Ok(Self { store, - exports: Exports { plugin, syntax, providers, views }, + exports: Exports { plugin, syntax, providers, views, indexer }, limits: setup.limits, version: setup.version, }) @@ -651,6 +658,47 @@ impl Runtime { self.call(Invocation::default(), timeout, |exports, store| f(exports.syntax.as_ref().expect("checked"), store)) } + pub fn index_document( + &mut self, + path: &str, + text: &str, + symbols: &[crate::indexing::IndexSymbol], + invocation: Invocation, + ) -> Result { + if self.exports.indexer.is_none() { + return Err(CallError::missing_export("document-indexer")); + } + let doc = wit_index::Document { + path: path.into(), + text: text.into(), + symbols: symbols + .iter() + .map(|s| wit_index::Symbol { + name: s.name.clone(), + kind: s.kind.clone(), + container: s.container.clone(), + range: range_to_wit(s.range), + }) + .collect(), + }; + let edges = self.call(invocation, self.limits.timeout, |e, store| { + e.indexer.as_ref().expect("checked").call_index(store, &doc) + })?; + fn anchor(a: wit_index::Anchor) -> crate::indexing::Anchor { + crate::indexing::Anchor { key: a.key, range: range_from_wit(a.range) } + } + Ok(crate::indexing::Edges { + definitions: edges.definitions.into_iter().map(anchor).collect(), + references: edges.references.into_iter().map(anchor).collect(), + aliases: edges + .aliases + .into_iter() + .map(|a| crate::indexing::Alias { prefix: a.prefix, target: a.target }) + .collect(), + markers: edges.markers.into_iter().map(anchor).collect(), + }) + } + /// The component exports `providers`. pub fn has_providers(&self) -> bool { self.exports.providers.is_some() diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs index a1f7b7533..c2154f365 100644 --- a/crates/ide-plugin/src/wit_host.rs +++ b/crates/ide-plugin/src/wit_host.rs @@ -696,3 +696,10 @@ impl views_bindings::openidea::plugin::view_types::Host for StoreData {} /// Largest message between a plugin and its view's frame. const MAX_VIEW_MESSAGE: usize = 1 << 20; + +pub(crate) mod indexing_bindings { + wasmtime::component::bindgen!({ + world: "indexing", path: "../../wit", + with: { "openidea:plugin/types": super::bindings::openidea::plugin::types }, + }); +} diff --git a/crates/ide-plugin/tests/api_compat.rs b/crates/ide-plugin/tests/api_compat.rs index 7ec2a34e2..f6f14abd9 100644 --- a/crates/ide-plugin/tests/api_compat.rs +++ b/crates/ide-plugin/tests/api_compat.rs @@ -23,9 +23,12 @@ fn repo() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") } -/// Every component the repository builds for users: the official plugins with code, plus the `hello` fixture. +/// Official components plus fixtures for the base and optional indexing exports. fn components() -> Vec { - let mut out = vec![repo().join("crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm")]; + let mut out = vec![ + repo().join("crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm"), + repo().join("crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm"), + ]; let official = repo().join("plugins/official"); let mut dirs: Vec<_> = std::fs::read_dir(&official).unwrap().map(|e| e.unwrap().path()).collect(); dirs.sort(); diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm index 1efc352b6..b818841e3 100644 Binary files a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources index e22d1ea8f..961a27e8e 100644 --- a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources @@ -1 +1 @@ -60d67ab91d3c42127500481510c43d829e07babcab872f94a6189d27c901234a +42044c4b2e3d768b922d6e5026ff4e725d37b265896ea7f383f1d737e2feffb7 diff --git a/crates/ide-plugin/tests/fixtures/indexer/Cargo.lock b/crates/ide-plugin/tests/fixtures/indexer/Cargo.lock new file mode 100644 index 000000000..ce35c853c --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/indexer/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-indexer" +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/indexer/Cargo.toml b/crates/ide-plugin/tests/fixtures/indexer/Cargo.toml new file mode 100644 index 000000000..03fb879fc --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/indexer/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "openidea-test-indexer" +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/indexer/README.md b/crates/ide-plugin/tests/fixtures/indexer/README.md new file mode 100644 index 000000000..fa06eefa7 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/indexer/README.md @@ -0,0 +1,10 @@ +# Document indexer test fixture + +A small line-oriented test language (`.idx`), unrelated to any framework: +`def key`, `ref key`, `alias prefix# target#` and `symbols prefix`. +References also produce gutter markers. `symbols` uses host-supplied UTF-16 +syntax ranges. `invalid key` deliberately returns an out-of-bounds range. + +Used by `cargo test -p ide-host --test plugin_indexing` to exercise the optional +WIT export, lifecycle invalidation, permissions, per-window isolation, candidate +selection and warm memory-only lookups. It is not a marketplace plugin. diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm new file mode 100644 index 000000000..ce009dabd Binary files /dev/null and b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources new file mode 100644 index 000000000..0c492615c --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources @@ -0,0 +1 @@ +87eb2a28875abbc649b98d2a7f87263da217f9ad28f1395d96a61db5f781d9f0 diff --git a/crates/ide-plugin/tests/fixtures/indexer/plugin.toml b/crates/ide-plugin/tests/fixtures/indexer/plugin.toml new file mode 100644 index 000000000..a172e1422 --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/indexer/plugin.toml @@ -0,0 +1,11 @@ +id = "test.indexer" +name = "Document indexer fixture" +version = "0.1.0" +engines.openidea = "^1.2" +wasm = "dist/plugin.wasm" + +[capabilities] +required = ["project.read"] + +[[contributes.documentIndexers]] +extensions = ["idx"] diff --git a/crates/ide-plugin/tests/fixtures/indexer/src/lib.rs b/crates/ide-plugin/tests/fixtures/indexer/src/lib.rs new file mode 100644 index 000000000..f5a1fdc9f --- /dev/null +++ b/crates/ide-plugin/tests/fixtures/indexer/src/lib.rs @@ -0,0 +1,51 @@ +//! Deliberately tiny line-oriented fixture, not a language/framework plugin. +use openidea_plugin::indexing::{Alias, Anchor, Document, DocumentIndexer, Edges}; +use openidea_plugin::{Error, Invocation, Plugin, Position, Range, register_plugin}; + +#[derive(Default)] +struct Indexer; +impl Plugin for Indexer { + fn run_command(&mut self, id: &str, _: &Invocation, _: Option<&str>) -> Result, Error> { + Err(Error::NotFound(id.into())) + } +} +impl DocumentIndexer for Indexer { + fn index(&mut self, doc: &Document) -> Edges { + let mut out = Edges::default(); + for (line, text) in doc.text.lines().enumerate() { + let Some((kind, value)) = text.split_once(' ') else { continue }; + let range = Range { + start: Position { line: line as u32, character: kind.len() as u32 + 1 }, + end: Position { line: line as u32, character: text.encode_utf16().count() as u32 }, + }; + let anchor = Anchor { key: value.into(), range }; + match kind { + "def" => out.definitions.push(anchor), + "ref" => { + out.references.push(anchor.clone()); + out.markers.push(anchor); + } + "alias" => { + if let Some((prefix, target)) = value.split_once(' ') { + out.aliases.push(Alias { prefix: prefix.into(), target: target.into() }); + } + } + "symbols" => out.definitions.extend( + doc.symbols + .iter() + .map(|symbol| Anchor { key: format!("{value}{}", symbol.name), range: symbol.range }), + ), + "invalid" => out.references.push(Anchor { + key: value.into(), + range: Range { + start: Position { line: 999, character: 0 }, + end: Position { line: 999, character: 1 }, + }, + }), + _ => {} + } + } + out + } +} +register_plugin!(Indexer, indexing); diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm index 58f3525f0..8b2e04da2 100644 Binary files a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources index 325eec7a1..deff9f831 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 @@ -6e9aab775c231987a73d2e424f8cd389e429f525327fac4f5aba550ebc53afe6 +3d3e43d4e49efa7a97984335f7f2df2cb286321bd0368251f37818aa6c1b70ad 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 c6ce9c5a7..b6b627bd4 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 @@ -ab066ebab16ba774c2f649a72469bcd25aaa1ffaee77f70ea67395ea26ddee02 +1d8561e04cb777efbafc6f64963473b9b69654a72ebb2be34f49c6c53c6f76e2 diff --git a/crates/ide-workspace/src/documents.rs b/crates/ide-workspace/src/documents.rs index cf64493e7..577b626da 100644 --- a/crates/ide-workspace/src/documents.rs +++ b/crates/ide-workspace/src/documents.rs @@ -741,6 +741,11 @@ impl Documents { self.with_doc(doc, |st| Ok(st.dirty)) } + /// Path and version for navigation queries, without copying document text. + pub fn identity(&self, doc: DocId) -> IdeResult<(PathBuf, u32)> { + self.with_doc(doc, |st| Ok((st.path.clone(), st.doc.version()))) + } + /// Current generation without copying the document text. pub fn version(&self, doc: DocId) -> IdeResult { self.with_doc(doc, |st| Ok(st.doc.version())) diff --git a/docs/plugins/indexing.md b/docs/plugins/indexing.md new file mode 100644 index 000000000..af146dd7c --- /dev/null +++ b/docs/plugins/indexing.md @@ -0,0 +1,54 @@ +# Background document indexers (API 1.2) + +Use `contributes.providers` for interactive definition, completion and line-marker +requests about one document. A document indexer is for cross-file associations +that need the whole project: it publishes keys once per document, and the host +resolves clicks and gutter markers from memory, without scanning files or +calling the plugin. + +```toml +[[contributes.documentIndexers]] +extensions = ["java", "xml"] +``` + +Implement `openidea_plugin::indexing::DocumentIndexer` and register with +`register_plugin!(Plugin, indexing)` (or `register_plugin!(Plugin, providers, indexing)` +when the plugin also has interactive providers). Components built with other +tools target the `plugin-indexing` world. Indexers require `project.read` and run +only in trusted projects. + +`index` gets the document's text (the editor's while it is open) and the symbols +the host's syntax support found in it, and returns: + +- `definitions`: where keys are declared; +- `references`: ranges that go to the definitions of their key on Go to + Declaration and Ctrl/⌘-click. Elsewhere the language server answers as usual; +- `aliases`: a `prefix#` whose keys also resolve under `target#`, for inherited + members. Chains are followed with bounded depth and candidates; +- `markers`: gutter icons that go to the definitions of their key. + +Keys are the plugin's own strings and ranges are UTF-16. When a key has +definitions in the same detected module, other modules' definitions are left +out. Several targets open a chooser. + +Initial discovery reuses the host's project index. The editor schedules +cancellable batches of at most 16 files; edits and file changes invalidate the +affected entries before they are indexed again. Reloading, disabling or a +failing runtime drops the plugin's graph, and nothing is answered from a +document version older than the editor's. + +Limits: 2 MiB per document, 20,000 files per graph, 4,096 definitions and +references and 4,096 markers per document, 256 aliases, 200,000 entries per +graph, 16 project/window graphs and 256 candidates per lookup. A document over +a limit is not indexed; editing is never blocked. + +## Minimal fixture + +`crates/ide-plugin/tests/fixtures/indexer` uses a tiny line-oriented `.idx` +language to exercise definitions, references, aliases, markers and host syntax +symbols without framework-specific parsing. Run +`cargo test -p ide-host --test plugin_indexing` for the real-component contract +checks. + +Framework plugins (MyBatis mappers, Rails routes) belong in their authors' +repositories and are listed through the registry; see `plugins/README.md`. diff --git a/plugins/README.md b/plugins/README.md index ed26a2a44..b4ebf4e76 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -159,6 +159,13 @@ key is true when any of its projects contains the path. It updates when files change. An IDE that doesn't know the key treats it as false: the plugin still installs and the key reads as false there. +### Background associations + +`[[contributes.documentIndexers]]` declares file `extensions` and requires the optional +`document-indexer` export. It publishes cross-file associations (definitions, references, +aliases and gutter markers) in background batches, not on the editing path. See +[Background document indexers](../docs/plugins/indexing.md). + ### Activation Code is instantiated lazily: when one of the plugin's commands runs, or when diff --git a/plugins/official/ai-completion/dist/plugin.wasm b/plugins/official/ai-completion/dist/plugin.wasm index d6d6e8109..a8f94345f 100644 Binary files a/plugins/official/ai-completion/dist/plugin.wasm and b/plugins/official/ai-completion/dist/plugin.wasm differ diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources index c52832903..ae7c9f9e4 100644 --- a/plugins/official/ai-completion/dist/plugin.wasm.sources +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -1 +1 @@ -f884ceac7f40567a2e817d3b40830a7b0da2f9d5ffa519bf67d4ff667c0f7da0 +5a707c0a9efb6f98f43d93f1b4207cc0ab21ac84d693e0fd9981d143e7552a1c diff --git a/plugins/official/checkstyle/dist/plugin.wasm b/plugins/official/checkstyle/dist/plugin.wasm index 13755f300..c7944b662 100644 Binary files a/plugins/official/checkstyle/dist/plugin.wasm and b/plugins/official/checkstyle/dist/plugin.wasm differ diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index 2f0c2d75f..9b9e9e3da 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -4c81f468df030724df26a6b57370d8ea4958a3cf8d3a40b23458ed3b2dd32505 +34728307b2054d6a494040f0523caf6f62b96c0f67f5b5e9a7e4508370e79419 diff --git a/plugins/official/csv-editor/dist/plugin.wasm b/plugins/official/csv-editor/dist/plugin.wasm index 2f19e9b85..788f3fc2f 100644 Binary files a/plugins/official/csv-editor/dist/plugin.wasm and b/plugins/official/csv-editor/dist/plugin.wasm differ diff --git a/plugins/official/csv-editor/dist/plugin.wasm.sources b/plugins/official/csv-editor/dist/plugin.wasm.sources index 6cae26b4b..e0795c324 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -266430921ca7b2271f452c2e8bc4893bd5c1d1516f8081a4ab09950ede9a586b +c8de2df9607fef2a7e75023e78db06ac83de8598a43632b684756d4e0df6b51b diff --git a/plugins/official/git-toolbox/dist/plugin.wasm b/plugins/official/git-toolbox/dist/plugin.wasm index 5ec79db63..0121a070d 100644 Binary files a/plugins/official/git-toolbox/dist/plugin.wasm and b/plugins/official/git-toolbox/dist/plugin.wasm differ diff --git a/plugins/official/git-toolbox/dist/plugin.wasm.sources b/plugins/official/git-toolbox/dist/plugin.wasm.sources index 8b8b88c5e..4ed042a00 100644 --- a/plugins/official/git-toolbox/dist/plugin.wasm.sources +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -1 +1 @@ -91f3f03a81acd298b26c539003a96d0e87a88139a2ec6b9ade85e9ca2e89d1a1 +2523894a6fb5a75d29429ab096fdd9acf150c05eb4ea13c9e92920c8d8ca4459 diff --git a/plugins/official/graphviz/dist/plugin.wasm b/plugins/official/graphviz/dist/plugin.wasm index 2ab1612de..75255e055 100644 Binary files a/plugins/official/graphviz/dist/plugin.wasm and b/plugins/official/graphviz/dist/plugin.wasm differ diff --git a/plugins/official/graphviz/dist/plugin.wasm.sources b/plugins/official/graphviz/dist/plugin.wasm.sources index 3cc505611..07811c63d 100644 --- a/plugins/official/graphviz/dist/plugin.wasm.sources +++ b/plugins/official/graphviz/dist/plugin.wasm.sources @@ -1 +1 @@ -e7cdaeb4f482f698026542c1545f3a1f4734f78f7e9ca0a83cdbc805669c2c7c +b534a088809e38ff210ed169b6968f3e793fa0fdcb12d022d2bcee5a58834514 diff --git a/plugins/official/key-promoter/dist/plugin.wasm b/plugins/official/key-promoter/dist/plugin.wasm index 3ae5c4261..76b901d4a 100644 Binary files a/plugins/official/key-promoter/dist/plugin.wasm and b/plugins/official/key-promoter/dist/plugin.wasm differ diff --git a/plugins/official/key-promoter/dist/plugin.wasm.sources b/plugins/official/key-promoter/dist/plugin.wasm.sources index 13f5a9704..e0b0643a4 100644 --- a/plugins/official/key-promoter/dist/plugin.wasm.sources +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -1 +1 @@ -ebdcbc36896ab0d545946a95befbc5aeb2b127d615c4754b0077975c53ee0286 +145bad8868c73368d4e83087861293fad309aaa87263bb12508a6acf693c25da diff --git a/plugins/official/license-header/dist/plugin.wasm b/plugins/official/license-header/dist/plugin.wasm index 6f41e7414..8659340b2 100644 Binary files a/plugins/official/license-header/dist/plugin.wasm and b/plugins/official/license-header/dist/plugin.wasm differ diff --git a/plugins/official/license-header/dist/plugin.wasm.sources b/plugins/official/license-header/dist/plugin.wasm.sources index d73677800..a280e4a5c 100644 --- a/plugins/official/license-header/dist/plugin.wasm.sources +++ b/plugins/official/license-header/dist/plugin.wasm.sources @@ -1 +1 @@ -786fd1808d786dd6abd2671b567db1e37075c08610ac4715c93c0624dbe396d6 +f2f4a7d3fbe4b86369e35969782dd66150df6ae4451e375122d3c1b232caeac1 diff --git a/plugins/official/mybatis/dist/plugin.wasm b/plugins/official/mybatis/dist/plugin.wasm index 6b0c733a4..4f71ecf9d 100644 Binary files a/plugins/official/mybatis/dist/plugin.wasm and b/plugins/official/mybatis/dist/plugin.wasm differ diff --git a/plugins/official/mybatis/dist/plugin.wasm.sources b/plugins/official/mybatis/dist/plugin.wasm.sources index cb12a22ee..e59d759a4 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -500b934eafbe02493b8d2b42f779281edd00b899f70ed8732de3edc377ceca63 +c29594e55d321ee5240f8f61223d72c9095695a5b19ff1f8a25c5a6402a4a736 diff --git a/plugins/official/regex-tester/dist/plugin.wasm b/plugins/official/regex-tester/dist/plugin.wasm index b49a3b605..e99b1f6a2 100644 Binary files a/plugins/official/regex-tester/dist/plugin.wasm and b/plugins/official/regex-tester/dist/plugin.wasm differ diff --git a/plugins/official/regex-tester/dist/plugin.wasm.sources b/plugins/official/regex-tester/dist/plugin.wasm.sources index 914149fed..46f075233 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -2ab43c67c74bd9f8d8ef788b8dca2f6f7288e79ad53663dc51624f36730e52f7 +01c6f9e9b1e76b3328294f6a31ba4d8f3545d04a4fa56cc8069c59aa37a13f27 diff --git a/plugins/official/solidity/dist/plugin.wasm b/plugins/official/solidity/dist/plugin.wasm index 06d84ab25..54d8b003a 100644 Binary files a/plugins/official/solidity/dist/plugin.wasm and b/plugins/official/solidity/dist/plugin.wasm differ diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources index 65e44bd13..6c4e41b3c 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -db60e5fccb72de283a772176052bf659a4ebff11bfb93f634dd513ec878837a7 +745e791fe2fde2b4d7b58b40a33a8037999aac947865a8f4a5127021b7f16156 diff --git a/plugins/official/string-tools/dist/plugin.wasm b/plugins/official/string-tools/dist/plugin.wasm index 46412ea4c..8ad1974a6 100644 Binary files a/plugins/official/string-tools/dist/plugin.wasm and b/plugins/official/string-tools/dist/plugin.wasm differ diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources index ff540a74c..5e33d0308 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -fa82a8596bd6505c769c0125ea26cef9af08535b36f650ddc4a353eb50866cea +4fff5b261e8255aa2dac8342694dee129ea4a8c9520105c719d0c16a16846517 diff --git a/sdk/rust/openidea-plugin/src/indexing.rs b/sdk/rust/openidea-plugin/src/indexing.rs new file mode 100644 index 000000000..bb55b9272 --- /dev/null +++ b/sdk/rust/openidea-plugin/src/indexing.rs @@ -0,0 +1,46 @@ +//! Background document indexing (`[[contributes.documentIndexers]]`): return +//! the keys a document defines and refers to; the host keeps the project-wide +//! graph and resolves Go to Declaration and gutter markers from it. Register +//! with `register_plugin!(Plugin, indexing)`. +use crate::Range; + +/// A symbol the host's syntax support found in the document. +#[derive(Clone, Debug)] +pub struct Symbol { + pub name: String, + pub kind: String, + pub container: Option, + pub range: Range, +} + +pub struct Document { + pub path: String, + pub text: String, + pub symbols: Vec, +} + +/// A range tied to a key the plugin chooses. +#[derive(Clone, Debug)] +pub struct Anchor { + pub key: String, + pub range: Range, +} + +/// Keys starting with `prefix` also resolve under `target` (both end with `#`). +pub struct Alias { + pub prefix: String, + pub target: String, +} + +#[derive(Default)] +pub struct Edges { + pub definitions: Vec, + pub references: Vec, + pub aliases: Vec, + /// Gutter icons that go to the definitions of their key. + pub markers: Vec, +} + +pub trait DocumentIndexer { + fn index(&mut self, doc: &Document) -> Edges; +} diff --git a/sdk/rust/openidea-plugin/src/lib.rs b/sdk/rust/openidea-plugin/src/lib.rs index ee9686957..ad2cce7bb 100644 --- a/sdk/rust/openidea-plugin/src/lib.rs +++ b/sdk/rust/openidea-plugin/src/lib.rs @@ -40,6 +40,7 @@ //! wasm32-wasip2`); see `plugins/README.md`. pub mod host; +pub mod indexing; #[cfg(not(target_arch = "wasm32"))] pub mod mock; pub mod providers; @@ -114,6 +115,17 @@ pub mod views_bindings { }); } +#[cfg(target_arch = "wasm32")] +#[doc(hidden)] +pub mod indexing_bindings { + wit_bindgen::generate!({ + world: "indexing", path: "wit", pub_export_macro: true, + export_macro_name: "__export_indexing_world", + default_bindings_module: "openidea_plugin::indexing_bindings", + with: { "openidea:plugin/types@1.2.0": crate::bindings::openidea::plugin::types }, + }); +} + /// The plugin API version this SDK targets. pub const API_VERSION: &str = "1.2"; @@ -164,6 +176,29 @@ macro_rules! register_plugin { $crate::bindings::__export_plugin_world!(__OpenideaComponent with_types_in $crate::bindings); }; }; + ($plugin:ty, indexing) => { + #[cfg(not(target_arch = "wasm32"))] + const _: fn() -> $plugin = <$plugin as ::core::default::Default>::default; + #[cfg(target_arch = "wasm32")] + const _: () = { + struct __OpenideaComponent; + $crate::__plugin_exports!($plugin, __OpenideaComponent, $crate::bindings); + $crate::__indexing_exports!($plugin, __OpenideaComponent); + $crate::bindings::__export_plugin_world!(__OpenideaComponent with_types_in $crate::bindings); + }; + }; + ($plugin:ty, providers, indexing) => { + #[cfg(not(target_arch = "wasm32"))] + const _: fn() -> $plugin = <$plugin as ::core::default::Default>::default; + #[cfg(target_arch = "wasm32")] + const _: () = { + struct __OpenideaComponent; + $crate::__plugin_exports!($plugin, __OpenideaComponent, $crate::bindings); + $crate::__providers_exports!($plugin, __OpenideaComponent); + $crate::__indexing_exports!($plugin, __OpenideaComponent); + $crate::bindings::__export_plugin_world!(__OpenideaComponent with_types_in $crate::bindings); + }; + }; ($plugin:ty, views) => { #[cfg(not(target_arch = "wasm32"))] const _: fn() -> $plugin = <$plugin as ::core::default::Default>::default; @@ -313,6 +348,20 @@ macro_rules! __views_exports { }; } +#[doc(hidden)] +#[macro_export] +macro_rules! __indexing_exports { + ($plugin:ty, $component:ident) => { + impl $crate::indexing_bindings::exports::openidea::plugin::document_indexer::Guest for $component { + fn index(doc: $crate::indexing_bindings::exports::openidea::plugin::document_indexer::Document) + -> $crate::indexing_bindings::exports::openidea::plugin::document_indexer::Edges { + $crate::__rt::index_document::<$plugin>(doc) + } + } + $crate::indexing_bindings::__export_indexing_world!($component with_types_in $crate::indexing_bindings); + }; +} + #[doc(hidden)] #[macro_export] macro_rules! __plugin_exports { @@ -522,6 +571,37 @@ pub mod __rt { .collect() } + pub fn index_document( + d: crate::indexing_bindings::exports::openidea::plugin::document_indexer::Document, + ) -> crate::indexing_bindings::exports::openidea::plugin::document_indexer::Edges { + use crate::indexing as i; + use crate::indexing_bindings::exports::openidea::plugin::document_indexer as w; + let doc = i::Document { + path: d.path, + text: d.text, + symbols: d + .symbols + .into_iter() + .map(|s| i::Symbol { + name: s.name, + kind: s.kind, + container: s.container, + range: crate::host::__range_from(s.range), + }) + .collect(), + }; + let e = with::(|p| p.index(&doc)); + fn anchor(a: i::Anchor) -> w::Anchor { + w::Anchor { key: a.key, range: crate::host::__range_to(a.range) } + } + w::Edges { + definitions: e.definitions.into_iter().map(anchor).collect(), + references: e.references.into_iter().map(anchor).collect(), + aliases: e.aliases.into_iter().map(|a| w::Alias { prefix: a.prefix, target: a.target }).collect(), + markers: e.markers.into_iter().map(anchor).collect(), + } + } + // ---- views ---- use crate::views as v; diff --git a/sdk/rust/openidea-plugin/wit/indexing.wit b/sdk/rust/openidea-plugin/wit/indexing.wit new file mode 100644 index 000000000..e9edc27d2 --- /dev/null +++ b/sdk/rust/openidea-plugin/wit/indexing.wit @@ -0,0 +1,60 @@ +package openidea:plugin@1.2.0; + +/// Cross-file associations a plugin publishes for the files it declares under +/// `[[contributes.documentIndexers]]`. The host calls `index` in cancellable +/// background batches (on project open, then for changed files), keeps the +/// project-wide graph, and answers Go to Declaration and gutter markers from +/// it without calling the plugin again. Requires `project.read`; trusted +/// projects only. +interface document-indexer { + use types.{range}; + + /// A symbol the host's syntax support found in the document. + record symbol { + name: string, + /// class, interface, method, field, … as in the structure view. + kind: string, + /// The enclosing symbol's name. + container: option, + range: range, + } + + /// The document to index: the editor's text while it is open, else the file's. + record document { + /// Absolute path. + path: string, + text: string, + symbols: list, + } + + /// A range of the document tied to a key the plugin chooses + /// (`com.example.UserMapper#findById`, say). Keys are compared as is. + record anchor { + key: string, + range: range, + } + + /// Keys starting with `prefix` also resolve as if they started with + /// `target`; both end with `#`. A mapper that extends another finds the + /// inherited statements this way. + record alias { + prefix: string, + target: string, + } + + /// What one document contributes; replaces its previous edges. + record edges { + /// Where keys are declared. + definitions: list, + /// Ranges that go to the definitions of their key (Go to Declaration, + /// Ctrl/⌘-click). Elsewhere the language server answers as usual. + references: list, + aliases: list, + /// Gutter icons on their first line that go to the definitions of their key. + markers: list, + } + + /// At most 4,096 definitions and references and 256 aliases per document; + /// a document over the limits is reported as not indexed. + index: func(doc: document) -> edges; +} diff --git a/sdk/rust/openidea-plugin/wit/plugin.wit b/sdk/rust/openidea-plugin/wit/plugin.wit index 35e154de1..12d2dc71b 100644 --- a/sdk/rust/openidea-plugin/wit/plugin.wit +++ b/sdk/rust/openidea-plugin/wit/plugin.wit @@ -50,6 +50,12 @@ world plugin-views { export view-provider; } +/// A plugin with a document indexer (`[[contributes.documentIndexers]]`). +world plugin-indexing { + include plugin; + export document-indexer; +} + /// Only the `syntax-provider` export: guests generate it next to `plugin` /// (the component tools merge both into one component). world syntax { @@ -65,3 +71,8 @@ world editor-providers { world views-provider { export view-provider; } + +/// Only the `document-indexer` export, generated next to `plugin` like `syntax`. +world indexing { + export document-indexer; +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 398f73d2f..3e4b0d47c 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -481,6 +481,11 @@ pub fn builder() -> tauri_specta::Builder { plugin::plugin_web_save_file, plugin::plugin_web_read_file, plugin::plugin_line_markers, + plugin::plugin_navigation_files, + plugin::plugin_navigation_invalidate, + plugin::plugin_navigation_index, + plugin::plugin_navigation_goto, + plugin::plugin_navigation_markers, 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 bb565af4f..989c906e1 100644 --- a/src-tauri/src/commands/plugin.rs +++ b/src-tauri/src/commands/plugin.rs @@ -633,3 +633,67 @@ pub fn plugin_set_theme( pub fn plugin_set_theme_frame(state: State<'_, AppState>, color: Option) -> IdeResult<()> { state.plugins.set_theme_frame(color.as_deref()) } + +use ide_host::plugins::index_ops::NavigationFiles; + +#[tauri::command] +#[specta::specta] +pub async fn plugin_navigation_files(window: Window, state: State<'_, AppState>) -> IdeResult { + ide_host::plugins::index_ops::plugin_navigation_files(&state.host, state.plugins.clone(), window.label()).await +} + +#[tauri::command] +#[specta::specta] +pub fn plugin_navigation_invalidate( + window: Window, + state: State<'_, AppState>, + paths: Option>, +) -> IdeResult<()> { + ide_host::plugins::index_ops::plugin_navigation_invalidate( + &state.host, + state.plugins.clone(), + window.label(), + paths, + ) +} + +#[tauri::command] +#[specta::specta] +pub async fn plugin_navigation_index( + window: Window, + state: State<'_, AppState>, + paths: Vec, + request: ide_core::RequestId, +) -> IdeResult<()> { + ide_host::plugins::index_ops::plugin_navigation_index( + &state.host, + state.plugins.clone(), + window.label(), + paths, + request, + ) + .await +} + +#[tauri::command] +#[specta::specta] +pub async fn plugin_navigation_goto( + window: Window, + state: State<'_, AppState>, + doc: ide_core::DocId, + pos: ide_core::Pos, +) -> IdeResult> { + ide_host::plugins::index_ops::plugin_navigation_goto(&state.host, state.plugins.clone(), window.label(), doc, pos) + .await +} + +#[tauri::command] +#[specta::specta] +pub async fn plugin_navigation_markers( + window: Window, + state: State<'_, AppState>, + paths: Vec, +) -> IdeResult> { + ide_host::plugins::index_ops::plugin_navigation_markers(&state.host, state.plugins.clone(), window.label(), paths) + .await +} diff --git a/src/bindings.ts b/src/bindings.ts index dd55fd6b0..9ede3d710 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1043,6 +1043,14 @@ export const commands = { pluginWebReadFile: (path: string) => typedError(__TAURI_INVOKE("plugin_web_read_file", { path })), /** Gutter markers of plugin `line-markers` providers for an open document. */ pluginLineMarkers: (doc: DocId) => typedError(__TAURI_INVOKE("plugin_line_markers", { doc })), + pluginNavigationFiles: () => typedError(__TAURI_INVOKE("plugin_navigation_files")), + pluginNavigationInvalidate: (paths: string[] | null) => typedError(__TAURI_INVOKE("plugin_navigation_invalidate", { paths })), + pluginNavigationIndex: (paths: string[], request: RequestId) => typedError(__TAURI_INVOKE("plugin_navigation_index", { paths, request })), + pluginNavigationGoto: (doc: DocId, pos: Pos) => typedError<{ + range: Range, + targets: Location[], +} | null, IdeError>(__TAURI_INVOKE("plugin_navigation_goto", { doc, pos })), + pluginNavigationMarkers: (paths: string[]) => typedError(__TAURI_INVOKE("plugin_navigation_markers", { paths })), /** 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"), @@ -3632,6 +3640,13 @@ export type FileLineStats = { removed: number, }; +/** The resolved gutter markers of one file. */ +export type FileMarkers = { + path: string, + version: number | null, + markers: NavigationResult[], +}; + export type FileMatch = { path: IpcPath, /** Path relative to the project root, as displayed. */ @@ -5346,6 +5361,17 @@ export type MethodStat = { total: number | null, }; +export type NavigationFiles = { + paths: IpcPath[], + indexing: boolean, + extensions: string[], +}; + +export type NavigationResult = { + range: Range, + targets: Location[], +}; + export type NewAgentTask = { /** Validated by the host; defaults to the window project root. */ projectRoot?: IpcPath | null, @@ -5592,6 +5618,8 @@ export type PluginEditor = { 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 marketplace offers newer versions of installed plugins (announced * once per set of versions). diff --git a/src/editor/lsp/navigation.ts b/src/editor/lsp/navigation.ts index 187b79c7a..ddf811ebf 100644 --- a/src/editor/lsp/navigation.ts +++ b/src/editor/lsp/navigation.ts @@ -1,3 +1,9 @@ +import { + contributedNavigation, + navigationEpoch, + onNavigationProvidersChanged, + type NavigationMatch, +} from "../navigationProviders"; /** * Go to declaration / implementation / type declaration (⌘B, ⌥⌘B, ⇧⌘B, * ⌘-click with a link underline while ⌘ is held, and a plain middle-button @@ -69,7 +75,7 @@ import { declarationSummary, renderCode } from "./markdown"; import { editorPrefs } from "@/platform/editorSettings"; import { isVirtualUri, virtualContainer } from "@/platform/virtualDocs"; import { dispatchMouseShortcut, shortcutLabel } from "@/platform/keymap"; -import { isNavigationGesture, onNavigationModifier } from "@/platform/navigationModifier"; +import { isNavigationGesture, onNavigationModifier, navigationModifierHeld } from "@/platform/navigationModifier"; import { create } from "zustand"; import { tx } from "@/platform/i18n"; @@ -251,6 +257,21 @@ export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number) const own = contributedAt(view.state, CONTRIBUTED_DEFINITION, offset); if (own?.(view, offset)) return; if (showPhpConstructDocumentation(view, offset)) return; + const before = view.state.doc; + const plugin = await contributedNavigation(view, offset).catch(() => null); + if (view.state.doc !== before) return; + if (plugin) { + if (plugin.targets.length === 1) await navigateTo(plugin.targets[0]!); + else + openPopup({ + kind: "list", + anchor: caretAnchor(view, offset), + title: tx`Choose Declaration`, + items: await locationItems(plugin.targets), + pin: pinToFind(view.state.sliceDoc(plugin.from, plugin.to), plugin.targets), + }); + return; + } } const doc = docOf(view); if (!doc) return; @@ -468,7 +489,12 @@ function linkAt(view: EditorView, x: number, y: number): { from: number; to: num * Whether ⌘-click at `offset` would navigate somewhere, with the declaration * info to show; null when there is no target (no underline then, as in IDEA). */ -async function probeNavigation(view: EditorView, offset: number): Promise<{ info: NavInfo | null } | null> { +async function probeNavigation( + view: EditorView, + offset: number, +): Promise<{ info: NavInfo | null; plugin?: NavigationMatch } | null> { + const plugin = await contributedNavigation(view, offset).catch(() => null); + if (plugin) return { info: null, plugin }; const doc = docOf(view); let navigable = !!contributedAt(view.state, CONTRIBUTED_DEFINITION, offset); if (!navigable && doc) { @@ -490,10 +516,12 @@ const linkPlugin = ViewPlugin.fromClass( class { last: { x: number; y: number } | null = null; /** Probe results per word (`from:to`) for the current document. */ - private probed = new Map(); + private probed = new Map(); private probedDoc: Text | null = null; + private providerEpoch = -1; private probing: string | null = null; private off: () => void; + private offProviders: () => void; private disposed = false; private hovered: string | null = null; private probeVersion = 0; @@ -503,6 +531,15 @@ const linkPlugin = ViewPlugin.fromClass( const offModifier = onNavigationModifier((held) => { if (!held || this.last) this.show(held); }); + this.offProviders = onNavigationProvidersChanged(() => { + if (this.disposed) return; + this.probeVersion++; + clearTimeout(this.probeTimer); + this.probing = null; + this.probed.clear(); + this.set(null); + if (this.last) this.show(navigationModifierHeld()); + }); // IDEA's macOS "Force touch" shortcut: a force click navigates like ⌘-click // (the keyboard dispatcher keeps the system Look Up popover away). const onForceDown = (e: Event) => { @@ -518,6 +555,7 @@ const linkPlugin = ViewPlugin.fromClass( } destroy() { this.off(); + this.offProviders(); this.disposed = true; this.probeVersion++; clearTimeout(this.probeTimer); @@ -560,14 +598,17 @@ const linkPlugin = ViewPlugin.fromClass( this.set({ from: target.from, to: target.to, info: null }); return; } - if (this.probedDoc !== view.state.doc) { + if (this.probedDoc !== view.state.doc || this.providerEpoch !== navigationEpoch()) { + this.providerEpoch = navigationEpoch(); this.probed.clear(); this.probedDoc = view.state.doc; } const key = `${target.from}:${target.to}`; if (this.probed.has(key)) { const hit = this.probed.get(key); - this.set(hit ? { from: target.from, to: target.to, info: hit.info } : null); + this.set( + hit ? { from: hit.plugin?.from ?? target.from, to: hit.plugin?.to ?? target.to, info: hit.info } : null, + ); return; } const current = view.state.field(linkField); @@ -584,15 +625,23 @@ const linkPlugin = ViewPlugin.fromClass( } }, 3000); const docAt = view.state.doc; + const epochAt = navigationEpoch(); void probeNavigation(view, target.from) .then((result) => { - if (this.disposed || view.state.doc !== docAt || this.probeVersion !== version) return; + if ( + this.disposed || + view.state.doc !== docAt || + this.probeVersion !== version || + epochAt !== navigationEpoch() + ) + return; clearTimeout(this.probeTimer); this.probed.set(key, result); if (this.probing !== key) return; this.probing = null; if (result) { - this.set({ from: target.from, to: target.to, info: null }); + this.set({ from: result.plugin?.from ?? target.from, to: result.plugin?.to ?? target.to, info: null }); + if (result.plugin) return; // Documentation must never delay the link cursor or underline. void requestHover(view, target.from) .then((hover) => { diff --git a/src/editor/lsp/navigationMouse.test.ts b/src/editor/lsp/navigationMouse.test.ts index ef49c770f..a02a935b7 100644 --- a/src/editor/lsp/navigationMouse.test.ts +++ b/src/editor/lsp/navigationMouse.test.ts @@ -1,3 +1,4 @@ +import { registerNavigationProvider } from "../navigationProviders"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; @@ -79,6 +80,23 @@ function click(init: MouseEventInit) { } describe("mouse navigation", () => { + it("uses plugin navigation for clicks without asking the language server", async () => { + const off = registerNavigationProvider(async () => ({ + from: 0, + to: 13, + targets: [{ path: "/p/Mapper.xml", range: { start: { line: 2, col: 4 }, end: { line: 2, col: 8 } } }], + })); + try { + click({ metaKey: true }); + await vi.waitFor(() => + expect(openFile).toHaveBeenCalledWith("/p/Mapper.xml", { selection: { line: 2, col: 4 }, focus: true }), + ); + expect(lspGoto).not.toHaveBeenCalled(); + } finally { + off(); + } + }); + it("opens the implementation of the symbol clicked with Option+Command", async () => { const event = click({ metaKey: true, altKey: true }); await vi.waitFor(() => diff --git a/src/editor/navigationProviders.test.ts b/src/editor/navigationProviders.test.ts new file mode 100644 index 000000000..e8669edc0 --- /dev/null +++ b/src/editor/navigationProviders.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { + contributedNavigation, + invalidateNavigationProviders, + registerNavigationProvider, + type NavigationMatch, +} from "./navigationProviders"; + +const disposers: (() => void)[] = []; +const views: EditorView[] = []; +afterEach(() => { + disposers.splice(0).forEach((f) => f()); + views.splice(0).forEach((v) => v.destroy()); +}); +function view() { + const v = new EditorView({ state: EditorState.create({ doc: "demo.UserMapper" }) }); + views.push(v); + return v; +} +const hit: NavigationMatch = { + from: 0, + to: 15, + targets: [{ path: "/src/UserMapper.java", range: { start: { line: 0, col: 0 }, end: { line: 0, col: 10 } } }], +}; + +describe("navigation providers", () => { + it("shares pending hover/click requests and whole-name results", async () => { + let calls = 0; + let finish!: (value: NavigationMatch) => void; + disposers.push( + registerNavigationProvider(() => { + calls++; + return new Promise((r) => { + finish = r; + }); + }), + ); + const v = view(); + const hover = contributedNavigation(v, 0); + const click = contributedNavigation(v, 0); + finish(hit); + expect(await hover).toEqual(hit); + expect(await click).toEqual(hit); + expect(await contributedNavigation(v, 8)).toEqual(hit); + expect(calls).toBe(1); + }); + it("drops results after edits and re-queries the new document", async () => { + let finish!: (value: NavigationMatch) => void; + const remove = registerNavigationProvider( + () => + new Promise((r) => { + finish = r; + }), + ); + disposers.push(remove); + const v = view(); + const request = contributedNavigation(v, 0); + v.dispatch({ changes: { from: 0, insert: "x" } }); + finish(hit); + expect(await request).toBeNull(); + }); + it("invalidates misses after background indexing and removes disabled providers", async () => { + let ready = false; + let calls = 0; + const off = registerNavigationProvider(async () => { + calls++; + return ready ? hit : null; + }); + disposers.push(off); + const v = view(); + expect(await contributedNavigation(v, 0)).toBeNull(); + expect(await contributedNavigation(v, 0)).toBeNull(); + expect(calls).toBe(1); + ready = true; + invalidateNavigationProviders(); + expect(await contributedNavigation(v, 0)).toEqual(hit); + off(); + expect(await contributedNavigation(v, 0)).toBeNull(); + }); + it("drops old results after project/provider invalidation", async () => { + let finish!: (value: NavigationMatch) => void; + disposers.push( + registerNavigationProvider( + () => + new Promise((r) => { + finish = r; + }), + ), + ); + const request = contributedNavigation(view(), 0); + invalidateNavigationProviders(); + finish(hit); + expect(await request).toBeNull(); + }); +}); diff --git a/src/editor/navigationProviders.ts b/src/editor/navigationProviders.ts new file mode 100644 index 000000000..0cb197a16 --- /dev/null +++ b/src/editor/navigationProviders.ts @@ -0,0 +1,85 @@ +/** Optional, asynchronous navigation contributions. Results are versioned by + * the editor text and provider epoch; hover and click share bounded requests. */ +import type { EditorView } from "@codemirror/view"; +import type { Location } from "@/platform/ipc"; + +export interface NavigationMatch { + from: number; + to: number; + targets: Location[]; +} +type Provider = (view: EditorView, offset: number) => Promise; +const providers = new Set(); +let epoch = 0; +export function navigationEpoch() { + return epoch; +} +const listeners = new Set<() => void>(); +let notifyPending = false; +export function onNavigationProvidersChanged(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} +export function invalidateNavigationProviders() { + epoch++; + if (!notifyPending) { + notifyPending = true; + queueMicrotask(() => { + notifyPending = false; + for (const listener of listeners) listener(); + }); + } +} +export function registerNavigationProvider(provider: Provider) { + providers.add(provider); + invalidateNavigationProviders(); + return () => { + providers.delete(provider); + invalidateNavigationProviders(); + }; +} +const caches = new WeakMap< + EditorView, + { + text: EditorView["state"]["doc"]; + epoch: number; + pending: Map>; + hits: NavigationMatch[]; + } +>(); +export async function contributedNavigation(view: EditorView, offset: number): Promise { + if (!providers.size) return null; + const text = view.state.doc; + let cache = caches.get(view); + if (!cache || cache.text !== text || cache.epoch !== epoch) { + cache = { text, epoch, pending: new Map(), hits: [] }; + caches.set(view, cache); + } + const hit = cache.hits.find((h) => h.from <= offset && offset < h.to); + if (hit) return hit; + const pending = cache.pending.get(offset); + if (pending) return pending; + const current = cache; + const query = (async () => { + for (const provider of providers) { + const answer = await provider(view, offset); + if (current.epoch !== epoch || view.state.doc !== text) return null; + if (answer && answer.targets.length && answer.from >= 0 && answer.to <= text.length && answer.from < answer.to) { + current.hits.push(answer); + if (current.hits.length > 128) current.hits.shift(); + return answer; + } + } + return null; + })(); + if (cache.pending.size >= 128) cache.pending.delete(cache.pending.keys().next().value!); + cache.pending.set(offset, query); + try { + return await query; + } catch (error) { + cache.pending.delete(offset); + throw error; + } +} diff --git a/src/features/plugins/index.ts b/src/features/plugins/index.ts index bcf4c39eb..651ffa04e 100644 --- a/src/features/plugins/index.ts +++ b/src/features/plugins/index.ts @@ -34,6 +34,8 @@ import { startViewUi } from "./uiPoints"; import { importIntellijTheme, showInstalledIntellijThemes } from "./intellijImport"; import { IntellijThemesDialogHost } from "./IntellijThemesDialog"; import { startWorkspaceContains } from "./workspaceContains"; +import { startIndexedMarkers } from "./indexedMarkers"; +import { startPluginNavigation } from "./navigation"; import { installFromDisk, refreshPlugins, reloadPlugin, SETTINGS_PAGE_ID, updateAllPlugins, usePlugins } from "./store"; function registerPluginsPage(): () => void { @@ -143,6 +145,8 @@ export function activate(): () => void { }, ]), startPluginBridge(), + startPluginNavigation(), + startIndexedMarkers(), // Plugin custom editors (tab kind) and context menu entries. startViewUi((input) => void editorService().openInput(input)), startWorkspaceContains(), diff --git a/src/features/plugins/indexedMarkers.test.ts b/src/features/plugins/indexedMarkers.test.ts new file mode 100644 index 000000000..724e9f2e4 --- /dev/null +++ b/src/features/plugins/indexedMarkers.test.ts @@ -0,0 +1,70 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { offsetsToRange } from "@/editor/lsp/positions"; +import type { FileMarkers } from "@/platform/ipc"; +import { indexedMarkers } from "./indexedMarkers"; +import { usePluginMarkers } from "./markerStore"; + +const calls = vi.hoisted(() => ({ navigate: vi.fn(), popup: vi.fn() })); +vi.mock("@/editor/lsp/navigation", () => ({ navigateTo: calls.navigate, locationItems: async () => [] })); +vi.mock("@/editor/lsp/popupStore", () => ({ openPopup: calls.popup })); + +let view: EditorView | undefined; +afterEach(() => { + view?.destroy(); + view = undefined; + usePluginMarkers.setState({ entries: new Map() }); + vi.clearAllMocks(); +}); + +const source = '\n\n'; +function setup() { + view = new EditorView({ + parent: document.body, + state: EditorState.create({ doc: source, extensions: [indexedMarkers("/p/Query.xml")] }), + }); + const range = (from: number, to: number) => offsetsToRange(view!.state.doc, from, to); + const target = { path: "/p/Query.java", range: range(0, 4) }; + const at = source.indexOf("find"); + const entry: FileMarkers = { + path: "/p/Query.xml", + version: 1, + markers: [{ range: range(at, at + 4), targets: [target] }], + }; + usePluginMarkers.setState({ entries: new Map([[entry.path, entry]]) }); + return { entry, target }; +} +const button = () => view!.dom.querySelector(".cm-plugin-mapping-button"); + +it("puts a button on the marker's line that goes to its target", async () => { + const { target } = setup(); + await vi.waitFor(() => expect(button()).not.toBeNull()); + expect(button()!.title).toContain("Query.java:1"); + expect(button()!.getAttribute("aria-label")).toContain("Query.java:1"); + button()!.click(); + expect(calls.navigate).toHaveBeenCalledWith(target); +}); + +it("hides markers on edit and when the entry goes away", async () => { + setup(); + await vi.waitFor(() => expect(button()).not.toBeNull()); + view!.dispatch({ changes: { from: 0, insert: "\n" } }); + expect(button()).toBeNull(); + usePluginMarkers.setState({ entries: new Map() }); + await vi.waitFor(() => expect(button()).toBeNull()); +}); + +it("merges a line's targets and offers a chooser for several", async () => { + const { entry, target } = setup(); + const other = { ...target, path: "/p/Other.java" }; + usePluginMarkers.setState({ + entries: new Map([ + [entry.path, { ...entry, markers: [...entry.markers, { ...entry.markers[0]!, targets: [target, other] }] }], + ]), + }); + await vi.waitFor(() => expect(button()!.title).toContain("Other.java")); + expect(view!.dom.querySelectorAll(".cm-plugin-mapping-button")).toHaveLength(1); + button()!.click(); + await vi.waitFor(() => expect(calls.popup).toHaveBeenCalled()); +}); diff --git a/src/features/plugins/indexedMarkers.ts b/src/features/plugins/indexedMarkers.ts new file mode 100644 index 000000000..559db2796 --- /dev/null +++ b/src/features/plugins/indexedMarkers.ts @@ -0,0 +1,129 @@ +/** + * Gutter markers from plugins' document indexers, resolved by the host from + * its cached graph. A click goes to the marker's targets (a chooser when there + * are several); an edit hides the markers until the edited text is indexed. + */ +import { RangeSet, StateEffect, StateField, type Extension } from "@codemirror/state"; +import { EditorView, GutterMarker, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view"; +import { registerEditorContribution } from "@/editor/api"; +import { rangeToOffsets } from "@/editor/lsp/positions"; +import { editorPrefs } from "@/platform/editorSettings"; +import { useProject } from "@/platform/project"; +import type { FileMarkers, Location } from "@/platform/ipc"; +import { markerIcon } from "./lineMarkerGutter"; +import { usePluginMarkers, watchPluginMarkers } from "./markerStore"; +import { navigationButton } from "./navigationButton"; + +class MappingMarker extends GutterMarker { + constructor(readonly targets: Location[]) { + super(); + } + override eq(other: GutterMarker) { + return other instanceof MappingMarker && JSON.stringify(other.targets) === JSON.stringify(this.targets); + } + override toDOM() { + return navigationButton(this.targets, markerIcon(null)); + } +} + +const setMarkers = StateEffect.define>(); +const markersField = StateField.define>({ + create: () => RangeSet.empty, + update(value, tr) { + if (tr.docChanged) value = RangeSet.empty; + for (const effect of tr.effects) if (effect.is(setMarkers)) value = effect.value; + return value; + }, +}); + +/** One marker per line, with the targets of every marker on it. */ +export function markersOf(view: EditorView, entry: FileMarkers | undefined): RangeSet { + if (!entry || !editorPrefs().showGutterIcons) return RangeSet.empty; + const doc = view.state.doc; + const rows = new Map(); + for (const marker of entry.markers) { + const line = doc.lineAt(rangeToOffsets(doc, marker.range).from).from; + const targets = rows.get(line) ?? []; + for (const target of marker.targets) + if (!targets.some((t) => t.path === target.path && JSON.stringify(t.range) === JSON.stringify(target.range))) + targets.push(target); + rows.set(line, targets); + } + return RangeSet.of( + [...rows].map(([pos, targets]) => new MappingMarker(targets).range(pos)), + true, + ); +} + +export function indexedMarkers(path: string): Extension { + return [ + markersField, + gutter({ class: "cm-plugin-mapping-gutter", markers: (v) => v.state.field(markersField) }), + EditorView.baseTheme({ + ".cm-plugin-mapping-button": { + border: "none", + padding: "0 2px", + background: "transparent", + color: "inherit", + cursor: "pointer", + height: "100%", + display: "flex", + alignItems: "center", + }, + ".cm-plugin-mapping-button svg": { width: "16px", height: "16px" }, + ".cm-plugin-mapping-button:focus-visible": { outline: "1px solid var(--accent)" }, + }), + ViewPlugin.fromClass( + class { + last: FileMarkers | undefined; + queued = false; + disposed = false; + readonly off: () => void; + readonly unwatch: () => void; + readonly prefs: () => void; + constructor(readonly view: EditorView) { + this.off = usePluginMarkers.subscribe(() => this.schedule()); + this.unwatch = watchPluginMarkers(path); + this.prefs = useProject.subscribe((s, prev) => { + if (s.settings !== prev.settings) { + this.last = undefined; + this.schedule(); + } + }); + this.schedule(); + } + update(update: ViewUpdate) { + if (update.docChanged) this.last = undefined; + } + schedule() { + if (this.queued || this.disposed) return; + this.queued = true; + queueMicrotask(() => { + this.queued = false; + if (!this.disposed) this.refresh(); + }); + } + refresh() { + const entry = usePluginMarkers.getState().entries.get(path); + if (entry && entry === this.last) return; + this.last = entry; + this.view.dispatch({ effects: setMarkers.of(markersOf(this.view, entry)) }); + } + destroy() { + this.disposed = true; + this.off(); + this.unwatch(); + this.prefs(); + } + }, + ), + ]; +} + +export function startIndexedMarkers() { + return registerEditorContribution({ + id: "plugins.indexedMarkers", + order: 65, + extensions: (ctx) => (ctx.virtual || ctx.doc < 0 ? [] : indexedMarkers(ctx.path)), + }); +} diff --git a/src/features/plugins/lineMarkerGutter.test.ts b/src/features/plugins/lineMarkerGutter.test.ts new file mode 100644 index 000000000..61180e6d5 --- /dev/null +++ b/src/features/plugins/lineMarkerGutter.test.ts @@ -0,0 +1,56 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import type { EditorDocContext } from "@/editor/api"; +import type { PluginEvent } from "@/bindings"; +import { pluginLineMarkerGutter } from "./lineMarkerGutter"; +const mock = vi.hoisted(() => ({ + markers: vi.fn(), + event: undefined as ((event: PluginEvent) => void) | undefined, + off: vi.fn(), +})); +vi.mock(import("@/platform/ipc"), async (original) => { + const actual = await original(); + return { ...actual, commands: { ...actual.commands, pluginLineMarkers: mock.markers } }; +}); +vi.mock("./backend", () => ({ + pluginsBackend: () => ({ + onEvent: (fn: (event: PluginEvent) => void) => { + mock.event = fn; + return mock.off; + }, + }), +})); +vi.mock("./navigationButton", () => ({ navigationButton: () => document.createElement("button") })); +let view: EditorView | undefined; +afterEach(() => { + view?.destroy(); + view = undefined; + vi.useRealTimers(); + vi.clearAllMocks(); +}); +it("withdraws old interactive markers on reload and does not refresh on idle status changes", async () => { + vi.useFakeTimers(); + mock.markers.mockResolvedValue({ + status: "ok", + data: [{ pluginId: "test", line: 0, icon: "navigate", tooltip: "old", target: null }], + }); + view = new EditorView({ + parent: document.body, + state: EditorState.create({ doc: "select", extensions: pluginLineMarkerGutter({ doc: 1 } as EditorDocContext) }), + }); + await vi.advanceTimersByTimeAsync(450); + expect(view.dom.querySelector(".cm-openidea-gutter-pluginMarkers")).not.toBeNull(); + const calls = mock.markers.mock.calls.length; + mock.event!({ type: "commandsChanged" }); + await vi.advanceTimersByTimeAsync(450); + expect(mock.markers).toHaveBeenCalledTimes(calls); + mock.markers.mockResolvedValue({ status: "ok", data: [] }); + mock.event!({ type: "documentIndexesChanged" }); + expect(view.dom.querySelector(".cm-openidea-gutter-pluginMarkers")).toBeNull(); + await vi.advanceTimersByTimeAsync(450); + expect(view.dom.querySelector(".cm-openidea-gutter-pluginMarkers")).toBeNull(); + view.destroy(); + view = undefined; + expect(mock.off).toHaveBeenCalledOnce(); +}); diff --git a/src/features/plugins/lineMarkerGutter.ts b/src/features/plugins/lineMarkerGutter.ts index 23e3149fc..4fa34cb2c 100644 --- a/src/features/plugins/lineMarkerGutter.ts +++ b/src/features/plugins/lineMarkerGutter.ts @@ -1,6 +1,5 @@ /** - * Gutter icons from plugins' `line-markers` providers (MyBatis' arrows - * between a mapper interface and its XML). Asked once per document version + * Gutter icons from plugins' `line-markers` providers. Asked once per document version * (debounced); markers follow their lines in between; a click opens the * marker's target. The gutter column exists only while there are markers, * so editors without plugin providers keep their usual width. @@ -8,7 +7,8 @@ import { Compartment, RangeSet, StateEffect, StateField, type Extension, type Text } from "@codemirror/state"; import { EditorView, GutterMarker, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view"; import type { EditorDocContext } from "@/editor/api"; -import { navigateTo } from "@/editor/lsp/navigation"; +import { navigationButton } from "./navigationButton"; +import { pluginsBackend } from "./backend"; import { commands, type LineMarkerInfo } from "@/platform/ipc"; import { gutterIconMarkup } from "@/ui"; import styles from "@/editor/navigation/LineMarkers.module.css"; @@ -58,14 +58,16 @@ class PluginMarker extends GutterMarker { return ( other instanceof PluginMarker && other.marker.tooltip === this.marker.tooltip && - other.marker.icon === this.marker.icon + other.marker.icon === this.marker.icon && + JSON.stringify(other.marker.target) === JSON.stringify(this.marker.target) ); } override toDOM(): Node { - const el = document.createElement("div"); - el.className = styles.marker!; + const el = this.marker.target + ? navigationButton([this.marker.target], markerIcon(this.marker.icon)) + : document.createElement("span"); + el.className = `${styles.marker} cm-plugin-mapping-button`; el.title = this.marker.tooltip; - el.setAttribute("role", "button"); el.setAttribute("aria-label", this.marker.tooltip); el.innerHTML = gutterIconMarkup(markerIcon(this.marker.icon)) ?? ""; return el; @@ -76,12 +78,20 @@ class MarkersPlugin { private timer: ReturnType | null = null; private generation = 0; private shown = false; + private off: () => void; constructor( private view: EditorView, private doc: number, private column: Compartment, ) { + this.off = pluginsBackend().onEvent((event) => { + if (event.type !== "documentIndexesChanged") return; + this.generation++; + this.shown = false; + this.view.dispatch({ effects: [setMarkers.of([]), this.column.reconfigure([])] }); + this.schedule(); + }); this.schedule(); } @@ -116,6 +126,7 @@ class MarkersPlugin { } destroy() { + this.off(); if (this.timer) clearTimeout(this.timer); this.generation++; } @@ -128,17 +139,6 @@ const markerColumn: Extension = gutter({ view.state.field(markersField).map((m) => new PluginMarker(m.marker).range(m.pos)), true, ), - domEventHandlers: { - mousedown: (view, block, event) => { - const e = event as MouseEvent; - if (e.button !== 0) return false; - const target = view.state.field(markersField).find((m) => m.pos === block.from && m.marker.target)?.marker.target; - if (!target) return false; - e.preventDefault(); - void navigateTo(target); - return true; - }, - }, }); export function pluginLineMarkerGutter(ctx: EditorDocContext): Extension { diff --git a/src/features/plugins/markerStore.test.ts b/src/features/plugins/markerStore.test.ts new file mode 100644 index 000000000..5c953c86c --- /dev/null +++ b/src/features/plugins/markerStore.test.ts @@ -0,0 +1,53 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { watchPluginMarkers, usePluginMarkers, invalidatePluginMarkers } from "./markerStore"; +import { invalidateNavigationProviders } from "@/editor/navigationProviders"; +const mock = vi.hoisted(() => vi.fn()); +vi.mock("@/platform/ipc", async (original) => ({ + ...(await original()), + isTauri: () => true, + commands: { pluginNavigationMarkers: mock }, +})); +const off: (() => void)[] = []; +afterEach(() => { + off.splice(0).forEach((f) => f()); + vi.useRealTimers(); + mock.mockReset(); +}); +it("batches visible paths, shares watchers and does no polling while idle", async () => { + vi.useFakeTimers(); + mock.mockResolvedValue({ status: "ok", data: [] }); + for (let i = 0; i < 130; i++) off.push(watchPluginMarkers(`/p/${i}.java`)); + off.push(watchPluginMarkers("/p/0.java")); + await vi.advanceTimersByTimeAsync(250); + expect(mock.mock.calls.map((a) => a[0].length)).toEqual([64, 64, 2]); + await vi.advanceTimersByTimeAsync(5000); + expect(mock).toHaveBeenCalledTimes(3); +}); +it("discards in-flight results after the project/provider epoch changes", async () => { + vi.useFakeTimers(); + let finish!: (r: unknown) => void; + mock.mockImplementationOnce(() => new Promise((r) => (finish = r))).mockResolvedValue({ status: "ok", data: [] }); + off.push(watchPluginMarkers("/old/A.xml")); + await vi.advanceTimersByTimeAsync(250); + invalidateNavigationProviders(); + finish({ status: "ok", data: [{ path: "/old/A.xml", version: 1, markers: [] }] }); + await vi.advanceTimersByTimeAsync(250); + expect(usePluginMarkers.getState().entries.size).toBe(0); +}); +it("invalidates both changed files and markers targeting them", () => { + const range = { start: { line: 0, col: 0 }, end: { line: 0, col: 1 } }; + usePluginMarkers.setState({ + entries: new Map([ + [ + "/p/A.xml", + { + path: "/p/A.xml", + version: 1, + markers: [{ range, targets: [{ path: "/p/A.java", range }] }], + }, + ], + ]), + }); + invalidatePluginMarkers(["/p/A.java"]); + expect(usePluginMarkers.getState().entries.size).toBe(0); +}); diff --git a/src/features/plugins/markerStore.ts b/src/features/plugins/markerStore.ts new file mode 100644 index 000000000..680344e5b --- /dev/null +++ b/src/features/plugins/markerStore.ts @@ -0,0 +1,83 @@ +/** Gutter markers of document indexers for the files open in editors: bounded IPC, no file reads. */ +import { create } from "zustand"; +import { call, commands, isTauri, type FileMarkers } from "@/platform/ipc"; +import { navigationEpoch, onNavigationProvidersChanged } from "@/editor/navigationProviders"; + +export const usePluginMarkers = create<{ entries: ReadonlyMap }>(() => ({ + entries: new Map(), +})); +const visible = new Map(); +let timer: ReturnType | undefined; +let running = false; +let session = 0; +let again = false; +let off: (() => void) | undefined; +function schedule() { + again = true; + if (!timer && !running) + timer = setTimeout(() => { + timer = undefined; + void refresh(); + }, 200); +} +async function refresh() { + if (!isTauri() || running) return; + running = true; + again = false; + const epoch = navigationEpoch(); + const current = session; + const paths = [...visible.keys()]; + const entries = new Map(); + try { + for (let i = 0; i < paths.length; i += 64) { + const results = await call(commands.pluginNavigationMarkers(paths.slice(i, i + 64))); + if (epoch !== navigationEpoch() || current !== session) return; + for (const result of results) { + if (!visible.has(result.path)) continue; + const previous = entries.get(result.path); + entries.set( + result.path, + previous ? { ...previous, markers: [...previous.markers, ...result.markers] } : result, + ); + } + } + if (epoch === navigationEpoch() && current === session) usePluginMarkers.setState({ entries }); + } catch { + // Plugin navigation may be unavailable during project close or reload. + if (epoch === navigationEpoch() && current === session) usePluginMarkers.setState({ entries: new Map() }); + } finally { + running = false; + if (again) schedule(); + } +} +export function watchPluginMarkers(path: string): () => void { + if (!path || !isTauri()) return () => {}; + if (!visible.size) + off = onNavigationProvidersChanged(() => { + schedule(); + }); + visible.set(path, (visible.get(path) ?? 0) + 1); + schedule(); + return () => { + const n = (visible.get(path) ?? 1) - 1; + if (n) visible.set(path, n); + else visible.delete(path); + if (!visible.size) { + session++; + off?.(); + off = undefined; + clearTimeout(timer); + timer = undefined; + again = false; + usePluginMarkers.setState({ entries: new Map() }); + } + }; +} +export function invalidatePluginMarkers(paths?: readonly string[]) { + const entries = new Map(usePluginMarkers.getState().entries); + for (const [path, entry] of entries) { + if (!paths || paths.includes(path) || entry.markers.some((m) => m.targets.some((t) => paths.includes(t.path)))) + entries.delete(path); + } + usePluginMarkers.setState({ entries }); +} diff --git a/src/features/plugins/navigation.test.ts b/src/features/plugins/navigation.test.ts new file mode 100644 index 000000000..df603bd8d --- /dev/null +++ b/src/features/plugins/navigation.test.ts @@ -0,0 +1,130 @@ +import { beforeEach, afterEach, expect, it, vi } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { useProject } from "@/platform/project"; +import { usePlugins } from "./store"; +import { useEditorRegistry } from "@/editor/api"; +import { lspDoc } from "@/editor/lsp/lsp"; + +const mocks = vi.hoisted(() => ({ + files: vi.fn(), + index: vi.fn(), + invalidate: vi.fn(), + cancel: vi.fn(), + events: new Map void>(), + pluginEvent: null as null | ((e: any) => void), +})); +vi.mock("./backend", () => ({ + pluginsBackend: () => ({ + onEvent: (callback: (e: any) => void) => { + mocks.pluginEvent = callback; + return () => { + mocks.pluginEvent = null; + }; + }, + }), +})); +vi.mock("@/platform/ipc", async (original) => { + const real = await original(); + return { + ...real, + isTauri: () => true, + onIdeEvent: (type: string, callback: (e: any) => void) => { + mocks.events.set(type, callback); + return () => mocks.events.delete(type); + }, + commands: { + ...real.commands, + pluginNavigationFiles: mocks.files, + pluginNavigationIndex: mocks.index, + pluginNavigationInvalidate: mocks.invalidate, + cancelRequest: mocks.cancel, + }, + }; +}); +const { startPluginNavigation } = await import("./navigation"); +let off: () => void; +let view: EditorView | null = null; +beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + mocks.events.clear(); + mocks.files.mockResolvedValue({ + status: "ok", + data: { paths: ["/project/A.java", "/project/A.xml"], indexing: false, extensions: ["xml", "java"] }, + }); + mocks.index.mockResolvedValue({ status: "ok", data: null }); + mocks.invalidate.mockResolvedValue({ status: "ok", data: null }); + mocks.cancel.mockResolvedValue(undefined); + useProject.setState({ project: { root: "/project", trusted: true } as never }); + usePlugins.setState({ + plugins: [ + { + id: "test", + version: "1", + status: "active", + contributions: [{ point: "documentIndexers", label: "xml, java" }], + } as never, + ], + }); +}); +afterEach(() => { + off?.(); + view?.destroy(); + view = null; + vi.useRealTimers(); + usePlugins.setState({ plugins: [] }); + useProject.setState({ project: null }); +}); + +it("prewarms once and indexes only changed paths after file events", async () => { + off = startPluginNavigation(); + await vi.advanceTimersByTimeAsync(250); + expect(mocks.files).toHaveBeenCalledTimes(1); + expect(mocks.index.mock.calls[0]?.[0]).toEqual(["/project/A.java", "/project/A.xml"]); + mocks.index.mockClear(); + mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.xml"], removed: [] }); + await vi.advanceTimersByTimeAsync(250); + expect(mocks.index.mock.calls[0]?.[0]).toEqual(["/project/A.xml"]); + mocks.events.get("indexState")?.({ indexing: false, extensions: ["xml", "java"] }); + await vi.advanceTimersByTimeAsync(100); + expect(mocks.files).toHaveBeenCalledTimes(1); +}); + +it("indexes unsaved editor changes and refreshes after same-version plugin reload", async () => { + off = startPluginNavigation(); + await vi.advanceTimersByTimeAsync(250); + const ctx = { doc: 1, path: "/project/A.xml", language: "xml", uiMode: "XML", readOnly: false }; + const extension = useEditorRegistry.getState().contributions.get("plugins.navigation")!; + view = new EditorView({ + state: EditorState.create({ doc: "", extensions: [lspDoc.of(ctx), extension.extensions(ctx)] }), + }); + await vi.advanceTimersByTimeAsync(250); + mocks.index.mockClear(); + view.dispatch({ changes: { from: 7, insert: " " } }); + await vi.advanceTimersByTimeAsync(250); + expect(mocks.index.mock.calls[0]?.[0]).toEqual(["/project/A.xml"]); + mocks.pluginEvent?.({ type: "documentIndexesChanged" }); + await vi.advanceTimersByTimeAsync(250); + expect(mocks.invalidate).toHaveBeenCalledWith(null); + expect(mocks.files).toHaveBeenCalledTimes(2); +}); + +it("cancels outstanding work when project context changes", async () => { + mocks.index.mockImplementation(() => new Promise(() => {})); + off = startPluginNavigation(); + await vi.advanceTimersByTimeAsync(250); + useProject.setState({ project: null }); + expect(mocks.cancel).toHaveBeenCalledTimes(1); +}); + +it("keeps warm indexes when API 1.0 reclaims an idle Wasm instance", async () => { + off = startPluginNavigation(); + await vi.advanceTimersByTimeAsync(250); + mocks.index.mockClear(); + mocks.pluginEvent?.({ type: "commandsChanged" }); + usePlugins.setState({ plugins: usePlugins.getState().plugins.map((p) => ({ ...p, status: "inactive" })) }); + await vi.advanceTimersByTimeAsync(250); + expect(mocks.files).toHaveBeenCalledTimes(1); + expect(mocks.index).not.toHaveBeenCalled(); +}); diff --git a/src/features/plugins/navigation.ts b/src/features/plugins/navigation.ts new file mode 100644 index 000000000..ebf44bc96 --- /dev/null +++ b/src/features/plugins/navigation.ts @@ -0,0 +1,207 @@ +/** Background navigation indexing. Opening a project discovers files once; + * edits and filesystem events then enqueue only affected paths. */ +import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"; +import { registerEditorContribution, whenSynced } from "@/editor/api"; +import { invalidateNavigationProviders, registerNavigationProvider } from "@/editor/navigationProviders"; +import { docOf } from "@/editor/lsp/lsp"; +import { offsetToPos, rangeToOffsets } from "@/editor/lsp/positions"; +import { call, commands, isCancelled, isTauri, nextRequestId, onIdeEvent } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { notifications } from "@/workbench/contributions"; +import { pluginsBackend } from "./backend"; +import { invalidatePluginMarkers } from "./markerStore"; +import { usePlugins } from "./store"; + +export function startPluginNavigation(): () => void { + if (!isTauri()) return () => {}; + let generation = 0; + let disposed = false; + let running = false; + let request: number | null = null; + let timer: ReturnType | undefined; + let pending = new Set(); + const dirty = new Set(); + const views = new Map(); + let discovered = false; + let discovering = false; + const known = new Set(); + let signature = ""; + let reportedError = false; + function report(error: unknown) { + console.warn("Plugin navigation", error); + if (reportedError) return; + reportedError = true; + notifications().notify({ + group: "plugins", + level: "warning", + title: "Plugin navigation indexing incomplete", + body: String(error), + }); + } + let fileExtensions = new Set(); + const extensions = () => fileExtensions; + const eligible = (path: string) => extensions().has(path.split(".").pop() ?? ""); + const ready = () => + !disposed && + !!useProject.getState().project?.trusted && + usePlugins + .getState() + .plugins.some( + (p) => + (p.status === "active" || p.status === "inactive") && + p.contributions.some((c) => c.point === "documentIndexers"), + ); + const notify = () => invalidateNavigationProviders(); + + async function flush() { + timer = undefined; + if (running || !ready() || !pending.size) return; + running = true; + const at = generation; + const paths = [...pending].slice(0, 8); + paths.forEach((p) => pending.delete(p)); + const id = nextRequestId(); + request = id; + try { + await Promise.all([...views].filter(([, p]) => paths.includes(p)).map(([v]) => whenSynced(v))); + if (at !== generation || !ready()) return; + await call(commands.pluginNavigationInvalidate(paths)); + await call(commands.pluginNavigationIndex(paths, id)); + if (at === generation) { + paths.forEach((p) => { + if (!pending.has(p)) dirty.delete(p); + }); + notify(); + } + } catch (error) { + if (at === generation && isCancelled(error)) paths.forEach((p) => pending.add(p)); + else if (at === generation) report(error); + } finally { + request = null; + running = false; + if (pending.size && ready()) timer = setTimeout(() => void flush(), 20); + } + } + function queue(paths: string[], priority = false) { + const matching = paths.filter(eligible); + if (!matching.length || !ready()) return; + invalidatePluginMarkers(matching); + matching.forEach((p) => dirty.add(p)); + pending = priority ? new Set([...matching, ...pending]) : new Set([...pending, ...matching]); + notify(); + clearTimeout(timer); + timer = setTimeout(() => void flush(), priority ? 150 : 30); + } + async function discover() { + if (!ready() || discovered || discovering) return; + discovering = true; + const at = generation; + try { + const inventory = await call(commands.pluginNavigationFiles()); + if (at !== generation) return; + fileExtensions = new Set(inventory.extensions); + discovered = !inventory.indexing; + const fresh = inventory.paths.filter((p) => !known.has(p)); + inventory.paths.forEach((p) => known.add(p)); + queue(fresh); + queue([...views.values()], true); + } catch (error) { + discovered = false; + report(error); + } finally { + if (at === generation) discovering = false; + if (!discovered && ready() && at === generation) setTimeout(() => void discover(), 1000); + } + } + function reset() { + invalidatePluginMarkers(); + generation++; + if (request !== null) void commands.cancelRequest(request); + clearTimeout(timer); + pending.clear(); + dirty.clear(); + known.clear(); + discovered = false; + discovering = false; + reportedError = false; + notify(); + if (ready()) + void call(commands.pluginNavigationInvalidate(null)) + .then(discover) + .catch(() => {}); + } + const offs = [ + pluginsBackend().onEvent((event) => { + if (event.type === "documentIndexesChanged") reset(); + }), + registerNavigationProvider(async (view, offset) => { + const doc = docOf(view); + if (!ready() || !doc || doc.virtual || !eligible(doc.path) || dirty.has(doc.path)) return null; + const before = view.state.doc; + await whenSynced(view); + const result = await call(commands.pluginNavigationGoto(doc.doc, offsetToPos(before, offset))); + if (!result || view.state.doc !== before) return null; + return { ...rangeToOffsets(before, result.range), targets: result.targets.filter((t) => !dirty.has(t.path)) }; + }), + registerEditorContribution({ + id: "plugins.navigation", + extensions: (ctx) => + ctx.virtual || ctx.doc < 0 + ? [] + : ViewPlugin.fromClass( + class { + constructor(readonly view: EditorView) { + views.set(view, ctx.path); + queue([ctx.path], true); + } + update(update: ViewUpdate) { + if (update.docChanged) queue([ctx.path], true); + } + destroy() { + views.delete(this.view); + queue([ctx.path]); + } + }, + ), + }), + onIdeEvent("fsChanged", (event) => { + const paths = [...event.created, ...event.modified, ...event.removed]; + queue(paths); + }), + onIdeEvent("indexState", (event) => { + if (!event.indexing) void discover(); + }), + onIdeEvent("projectRootsChanged", () => reset()), + useProject.subscribe((s, prev) => { + if (s.project?.root !== prev.project?.root || s.project?.trusted !== prev.project?.trusted) reset(); + }), + usePlugins.subscribe((state) => { + const next = state.plugins + .map( + (p) => + `${p.id}:${p.version}:${p.status === "active" || p.status === "inactive" ? "live" : p.status}:${JSON.stringify(p.contributions)}`, + ) + .join("|"); + if (next !== signature) { + signature = next; + reset(); + } + }), + ]; + // Index completion events may predate feature activation; querying the current + // inventory now is safe, and an incomplete inventory is retried on IndexState. + signature = usePlugins + .getState() + .plugins.map( + (p) => + `${p.id}:${p.version}:${p.status === "active" || p.status === "inactive" ? "live" : p.status}:${JSON.stringify(p.contributions)}`, + ) + .join("|"); + void discover(); + return () => { + disposed = true; + reset(); + offs.reverse().forEach((off) => off()); + views.clear(); + }; +} diff --git a/src/features/plugins/navigationButton.ts b/src/features/plugins/navigationButton.ts new file mode 100644 index 000000000..b7e1ebf2c --- /dev/null +++ b/src/features/plugins/navigationButton.ts @@ -0,0 +1,34 @@ +import { tx } from "@/platform/i18n"; +import { navigateTo, locationItems } from "@/editor/lsp/navigation"; +import { openPopup } from "@/editor/lsp/popupStore"; +import { gutterIconMarkup, isIconName } from "@/ui/icons"; +import type { Location } from "@/platform/ipc"; + +async function openTargets(targets: Location[], element: HTMLElement) { + if (targets.length === 1) { + await navigateTo(targets[0]!); + return; + } + const rect = element.getBoundingClientRect(); + openPopup({ + kind: "list", + title: tx`Choose Mapping Target`, + anchor: { x: rect.left, y: rect.bottom + 2 }, + items: await locationItems(targets), + }); +} +/** Shared ordinary-click and keyboard navigation for interactive and indexed markers. */ +export function navigationButton(targets: Location[], icon: string): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = "cm-plugin-mapping-button"; + button.title = targets.map((t) => `${t.path.split("/").pop()}:${t.range.start.line + 1}`).join("\n"); + button.setAttribute("aria-label", tx`Go to mapping: ${button.title}`); + button.innerHTML = gutterIconMarkup(isIconName(icon) ? icon : "arrow-right") ?? ""; + button.onclick = (event) => { + event.preventDefault(); + event.stopPropagation(); + void openTargets(targets, button); + }; + return button; +} diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 4a5f567a4..aa9ed6c2e 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -6329,5 +6329,9 @@ "HR (ALTER SESSION SET CURRENT_SCHEMA)": "HR(ALTER SESSION SET CURRENT_SCHEMA)", "Instant Client:": "Instant Client:", "Directory of Oracle Instant Client 19 or later. Empty: ORACLE_HOME or the system library path.": "Oracle Instant Client 19 或更高版本的目录。留空则使用 ORACLE_HOME 或系统库路径。", - "Session parameters set after connecting (ALTER SESSION SET name = value). connect_timeout is in seconds.": "连接后设置的会话参数(ALTER SESSION SET name = value)。connect_timeout 以秒为单位。" + "Session parameters set after connecting (ALTER SESSION SET name = value). connect_timeout is in seconds.": "连接后设置的会话参数(ALTER SESSION SET name = value)。connect_timeout 以秒为单位。", + "Choose Mapping Target": "选择映射目标", + "Plugin navigation indexing incomplete": "插件导航索引未完成", + "Choose Declaration": "选择声明", + "Go to mapping: {target}": "跳转到映射:{target}" } diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index c7339fbde..4faeac875 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -1124,3 +1124,8 @@ export function folderIconName(kind: FolderKind = "plain"): IconName { return "folder"; } } + +/** Validated built-in and registered, sanitized plugin icons. */ +export function isIconName(name: string): name is IconName { + return iconPath(name) !== undefined; +} diff --git a/wit/indexing.wit b/wit/indexing.wit new file mode 100644 index 000000000..e9edc27d2 --- /dev/null +++ b/wit/indexing.wit @@ -0,0 +1,60 @@ +package openidea:plugin@1.2.0; + +/// Cross-file associations a plugin publishes for the files it declares under +/// `[[contributes.documentIndexers]]`. The host calls `index` in cancellable +/// background batches (on project open, then for changed files), keeps the +/// project-wide graph, and answers Go to Declaration and gutter markers from +/// it without calling the plugin again. Requires `project.read`; trusted +/// projects only. +interface document-indexer { + use types.{range}; + + /// A symbol the host's syntax support found in the document. + record symbol { + name: string, + /// class, interface, method, field, … as in the structure view. + kind: string, + /// The enclosing symbol's name. + container: option, + range: range, + } + + /// The document to index: the editor's text while it is open, else the file's. + record document { + /// Absolute path. + path: string, + text: string, + symbols: list, + } + + /// A range of the document tied to a key the plugin chooses + /// (`com.example.UserMapper#findById`, say). Keys are compared as is. + record anchor { + key: string, + range: range, + } + + /// Keys starting with `prefix` also resolve as if they started with + /// `target`; both end with `#`. A mapper that extends another finds the + /// inherited statements this way. + record alias { + prefix: string, + target: string, + } + + /// What one document contributes; replaces its previous edges. + record edges { + /// Where keys are declared. + definitions: list, + /// Ranges that go to the definitions of their key (Go to Declaration, + /// Ctrl/⌘-click). Elsewhere the language server answers as usual. + references: list, + aliases: list, + /// Gutter icons on their first line that go to the definitions of their key. + markers: list, + } + + /// At most 4,096 definitions and references and 256 aliases per document; + /// a document over the limits is reported as not indexed. + index: func(doc: document) -> edges; +} diff --git a/wit/plugin.wit b/wit/plugin.wit index 35e154de1..12d2dc71b 100644 --- a/wit/plugin.wit +++ b/wit/plugin.wit @@ -50,6 +50,12 @@ world plugin-views { export view-provider; } +/// A plugin with a document indexer (`[[contributes.documentIndexers]]`). +world plugin-indexing { + include plugin; + export document-indexer; +} + /// Only the `syntax-provider` export: guests generate it next to `plugin` /// (the component tools merge both into one component). world syntax { @@ -65,3 +71,8 @@ world editor-providers { world views-provider { export view-provider; } + +/// Only the `document-indexer` export, generated next to `plugin` like `syntax`. +world indexing { + export document-indexer; +}