feat(plugins): add background document-indexer API (#1021)

Plugin API 1.2 gains an optional document-indexer export (plugin-indexing world). A plugin returns per document the keys it defines and refers to, aliases between key prefixes and gutter markers; the host indexes a trusted project in cancellable background batches, keeps the project-wide graph, and answers Go to Declaration / Ctrl-click and multi-target gutter markers from memory without calling the plugin again. Edits and file changes invalidate the affected entries; reload, disable or a failing runtime drops the plugin's graph.

The interface is limited to cross-file associations: icons chosen by file content and embedded-language regions are separate concerns for later interfaces. The plugin navigation choosers' titles are translated.

Co-authored-by: qjlqyf <[email protected]>
This commit is contained in:
qiaojianlong authored and GitHub committed 2026-11-06 10:07:53 +08:00
1 parent 9069c322e8
commit ed08b96382
79 files changed
+3039 -46

No files matched your search

+4
View File
@@ -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();
}
+11
View File
@@ -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<Vec<Location>> {
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;
+177
View File
@@ -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<ide_core::IpcPath>,
pub indexing: bool,
pub extensions: Vec<String>,
}
/// 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<PluginHost>,
window: &str,
) -> IdeResult<NavigationFiles> {
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<PluginHost>,
window: &str,
paths: Option<Vec<PathBuf>>,
) -> 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<PluginHost>,
window: &str,
paths: Vec<PathBuf>,
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<PluginHost>,
window: &str,
doc: ide_core::DocId,
pos: ide_core::Pos,
) -> IdeResult<Option<crate::plugins::navigation::NavigationResult>> {
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<PluginHost>,
window: &str,
paths: Vec<PathBuf>,
) -> IdeResult<Vec<crate::plugins::navigation::FileMarkers>> {
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<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
tokio::task::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
}
+9 -1
View File
@@ -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<run_detectors::RunDetectors>,
/// `[[contributes.providers]]` of enabled plugins.
providers: Arc<providers::Providers>,
navigation: Mutex<navigation::NavigationIndex>,
/// Validated UI declarations of installed packages (start-up reuses them).
index: Arc<ContributionIndex>,
ledger: Mutex<Ledger>,
@@ -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);
+462
View File
@@ -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<u32>,
pub text: &'a str,
pub symbols: &'a [SymbolTag],
pub scope: &'a str,
}
pub use ide_plugin::indexing::*;
struct File {
version: Option<u32>,
scope: String,
edges: Edges,
}
#[derive(Default)]
struct Graph {
instance: Weak<super::LoadedPlugin>,
files: BTreeMap<PathBuf, File>,
definitions: HashMap<String, Vec<Location>>,
aliases: HashMap<String, Vec<(PathBuf, String)>>,
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<String>), HashMap<String, Graph>>,
generation: u64,
epochs: HashMap<PathBuf, u64>,
}
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<String> {
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<Option<&str>>, paths: Option<&[PathBuf]>) {
let matches = |key: &(PathBuf, Option<String>)| 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::<Vec<_>>();
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::<usize>()
.saturating_add(edges.aliases.iter().map(|a| a.prefix.len() + a.target.len()).sum::<usize>());
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<u32>,
) -> Option<NavigationResult> {
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<NavigationResult> = 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<u32>,
) -> Vec<FileMarkers> {
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<String>,
}
fn declaration(p: &super::LoadedPlugin) -> Option<Decl> {
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<Prepared, String> {
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))
}
}
+1
View File
@@ -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;
+5 -1
View File
@@ -180,10 +180,14 @@ pub fn set_editor_providers(host: Option<&Arc<PluginHost>>) {
/// The plugin host whose providers take part, when any declares `kind`.
pub(crate) fn editor_providers(kind: ProviderKind) -> Option<Arc<PluginHost>> {
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<Arc<PluginHost>> {
EDITOR_PROVIDERS.read().as_ref()?.upgrade()
}
impl PluginHost {
/// Usable plugins declaring `kind` for `language`.
fn provider_plugins(&self, kind: ProviderKind, language: &str) -> Vec<Arc<LoadedPlugin>> {
@@ -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
});
+2
View File
@@ -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<PluginUpdate> },
+236
View File
@@ -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<u32>, 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());
}
+37
View File
@@ -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<Vec<ide_language::SymbolTag>> {
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<PathBuf> {
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<FileMatch> {
+20
View File
@@ -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", "<mapper/>"),
("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());
}
+53
View File
@@ -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<Anchor>,
pub references: Vec<Anchor>,
#[serde(default)]
pub aliases: Vec<Alias>,
#[serde(default)]
pub markers: Vec<Anchor>,
}
/// 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<u32>,
pub markers: Vec<NavigationResult>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct NavigationResult {
pub range: Range,
pub targets: Vec<Location>,
}
pub struct IndexSymbol {
pub name: String,
pub kind: String,
pub container: Option<String>,
pub range: Range,
}
+1
View File
@@ -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;
+50 -2
View File
@@ -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<wit_syntax::Guest>,
providers: Option<wit_prov::Guest>,
indexer: Option<wit_index::Guest>,
views: Option<wit_view::Guest>,
}
@@ -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<crate::indexing::Edges, CallError> {
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()
+7
View File
@@ -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 },
});
}
+5 -2
View File
@@ -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<PathBuf> {
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();
Binary file not shown.
@@ -1 +1 @@
60d67ab91d3c42127500481510c43d829e07babcab872f94a6189d27c901234a
42044c4b2e3d768b922d6e5026ff4e725d37b265896ea7f383f1d737e2feffb7
+333
View File
@@ -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"
+20
View File
@@ -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"
+10
View File
@@ -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.
Binary file not shown.
@@ -0,0 +1 @@
87eb2a28875abbc649b98d2a7f87263da217f9ad28f1395d96a61db5f781d9f0
+11
View File
@@ -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"]
+51
View File
@@ -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<Option<String>, 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);
Binary file not shown.
@@ -1 +1 @@
6e9aab775c231987a73d2e424f8cd389e429f525327fac4f5aba550ebc53afe6
3d3e43d4e49efa7a97984335f7f2df2cb286321bd0368251f37818aa6c1b70ad
@@ -1 +1 @@
ab066ebab16ba774c2f649a72469bcd25aaa1ffaee77f70ea67395ea26ddee02
1d8561e04cb777efbafc6f64963473b9b69654a72ebb2be34f49c6c53c6f76e2
+5
View File
@@ -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<u32> {
self.with_doc(doc, |st| Ok(st.doc.version()))
+54
View File
@@ -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`.
+7
View File
@@ -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
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
f884ceac7f40567a2e817d3b40830a7b0da2f9d5ffa519bf67d4ff667c0f7da0
5a707c0a9efb6f98f43d93f1b4207cc0ab21ac84d693e0fd9981d143e7552a1c
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
4c81f468df030724df26a6b57370d8ea4958a3cf8d3a40b23458ed3b2dd32505
34728307b2054d6a494040f0523caf6f62b96c0f67f5b5e9a7e4508370e79419
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
266430921ca7b2271f452c2e8bc4893bd5c1d1516f8081a4ab09950ede9a586b
c8de2df9607fef2a7e75023e78db06ac83de8598a43632b684756d4e0df6b51b
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
91f3f03a81acd298b26c539003a96d0e87a88139a2ec6b9ade85e9ca2e89d1a1
2523894a6fb5a75d29429ab096fdd9acf150c05eb4ea13c9e92920c8d8ca4459
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
e7cdaeb4f482f698026542c1545f3a1f4734f78f7e9ca0a83cdbc805669c2c7c
b534a088809e38ff210ed169b6968f3e793fa0fdcb12d022d2bcee5a58834514
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
ebdcbc36896ab0d545946a95befbc5aeb2b127d615c4754b0077975c53ee0286
145bad8868c73368d4e83087861293fad309aaa87263bb12508a6acf693c25da
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
786fd1808d786dd6abd2671b567db1e37075c08610ac4715c93c0624dbe396d6
f2f4a7d3fbe4b86369e35969782dd66150df6ae4451e375122d3c1b232caeac1
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
500b934eafbe02493b8d2b42f779281edd00b899f70ed8732de3edc377ceca63
c29594e55d321ee5240f8f61223d72c9095695a5b19ff1f8a25c5a6402a4a736
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
2ab43c67c74bd9f8d8ef788b8dca2f6f7288e79ad53663dc51624f36730e52f7
01c6f9e9b1e76b3328294f6a31ba4d8f3545d04a4fa56cc8069c59aa37a13f27
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
db60e5fccb72de283a772176052bf659a4ebff11bfb93f634dd513ec878837a7
745e791fe2fde2b4d7b58b40a33a8037999aac947865a8f4a5127021b7f16156
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
fa82a8596bd6505c769c0125ea26cef9af08535b36f650ddc4a353eb50866cea
4fff5b261e8255aa2dac8342694dee129ea4a8c9520105c719d0c16a16846517
+46
View File
@@ -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<String>,
pub range: Range,
}
pub struct Document {
pub path: String,
pub text: String,
pub symbols: Vec<Symbol>,
}
/// 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<Anchor>,
pub references: Vec<Anchor>,
pub aliases: Vec<Alias>,
/// Gutter icons that go to the definitions of their key.
pub markers: Vec<Anchor>,
}
pub trait DocumentIndexer {
fn index(&mut self, doc: &Document) -> Edges;
}
+80
View File
@@ -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/[email protected]": 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<T: Plugin + crate::indexing::DocumentIndexer + Default>(
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::<T, _>(|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;
+60
View File
@@ -0,0 +1,60 @@
package openidea:[email protected];
/// 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<string>,
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<symbol>,
}
/// 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<anchor>,
/// Ranges that go to the definitions of their key (Go to Declaration,
/// Ctrl/⌘-click). Elsewhere the language server answers as usual.
references: list<anchor>,
aliases: list<alias>,
/// Gutter icons on their first line that go to the definitions of their key.
markers: list<anchor>,
}
/// 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;
}
+11
View File
@@ -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;
}
+5
View File
@@ -481,6 +481,11 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+64
View File
@@ -633,3 +633,67 @@ pub fn plugin_set_theme(
pub fn plugin_set_theme_frame(state: State<'_, AppState>, color: Option<String>) -> 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<NavigationFiles> {
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<Vec<PathBuf>>,
) -> 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<PathBuf>,
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<Option<ide_plugin::indexing::NavigationResult>> {
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<PathBuf>,
) -> IdeResult<Vec<ide_plugin::indexing::FileMarkers>> {
ide_host::plugins::index_ops::plugin_navigation_markers(&state.host, state.plugins.clone(), window.label(), paths)
.await
}
+28
View File
@@ -1043,6 +1043,14 @@ export const commands = {
pluginWebReadFile: (path: string) => typedError<string, IdeError>(__TAURI_INVOKE("plugin_web_read_file", { path })),
/** Gutter markers of plugin `line-markers` providers for an open document. */
pluginLineMarkers: (doc: DocId) => typedError<LineMarkerInfo[], IdeError>(__TAURI_INVOKE("plugin_line_markers", { doc })),
pluginNavigationFiles: () => typedError<NavigationFiles, IdeError>(__TAURI_INVOKE("plugin_navigation_files")),
pluginNavigationInvalidate: (paths: string[] | null) => typedError<null, IdeError>(__TAURI_INVOKE("plugin_navigation_invalidate", { paths })),
pluginNavigationIndex: (paths: string[], request: RequestId) => typedError<null, IdeError>(__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<FileMarkers[], IdeError>(__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<MarketPlugin[], IdeError>(__TAURI_INVOKE("plugin_market", { refresh })),
pluginMarketUrl: () => __TAURI_INVOKE<string | null>("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).
+56 -7
View File
@@ -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<ContributedDefinition>(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<ContributedDefinition>(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<string, { info: NavInfo | null } | null>();
private probed = new Map<string, { info: NavInfo | null; plugin?: NavigationMatch } | null>();
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) => {
+18
View File
@@ -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(() =>
+97
View File
@@ -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();
});
});
+85
View File
@@ -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<NavigationMatch | null>;
const providers = new Set<Provider>();
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<number, Promise<NavigationMatch | null>>;
hits: NavigationMatch[];
}
>();
export async function contributedNavigation(view: EditorView, offset: number): Promise<NavigationMatch | null> {
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;
}
}
+4
View File
@@ -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(),
@@ -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 = '<mapper>\n<select id="find">SELECT 1</select>\n</mapper>';
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<HTMLButtonElement>(".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());
});
+129
View File
@@ -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<RangeSet<GutterMarker>>();
const markersField = StateField.define<RangeSet<GutterMarker>>({
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<GutterMarker> {
if (!entry || !editorPrefs().showGutterIcons) return RangeSet.empty;
const doc = view.state.doc;
const rows = new Map<number, Location[]>();
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)),
});
}
@@ -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();
});
+18 -18
View File
@@ -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<typeof setTimeout> | 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 {
+53
View File
@@ -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<typeof import("@/platform/ipc")>()),
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);
});
+83
View File
@@ -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<string, FileMarkers> }>(() => ({
entries: new Map(),
}));
const visible = new Map<string, number>();
let timer: ReturnType<typeof setTimeout> | 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<string, FileMarkers>();
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 });
}
+130
View File
@@ -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<string, (e: any) => 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<typeof import("@/platform/ipc")>();
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: "<mapper/>", 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();
});
+207
View File
@@ -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<typeof setTimeout> | undefined;
let pending = new Set<string>();
const dirty = new Set<string>();
const views = new Map<EditorView, string>();
let discovered = false;
let discovering = false;
const known = new Set<string>();
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<string>();
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();
};
}
+34
View File
@@ -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;
}
+5 -1
View File
@@ -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}"
}
+5
View File
@@ -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;
}
+60
View File
@@ -0,0 +1,60 @@
package openidea:[email protected];
/// 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<string>,
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<symbol>,
}
/// 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<anchor>,
/// Ranges that go to the definitions of their key (Go to Declaration,
/// Ctrl/⌘-click). Elsewhere the language server answers as usual.
references: list<anchor>,
aliases: list<alias>,
/// Gutter icons on their first line that go to the definitions of their key.
markers: list<anchor>,
}
/// 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;
}
+11
View File
@@ -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;
}