mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
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:
1 parent
9069c322e8
commit
ed08b96382
79 files changed
+3039
-46
No files matched your search
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()))?
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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> },
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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()))
|
||||
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
f884ceac7f40567a2e817d3b40830a7b0da2f9d5ffa519bf67d4ff667c0f7da0
|
||||
5a707c0a9efb6f98f43d93f1b4207cc0ab21ac84d693e0fd9981d143e7552a1c
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
4c81f468df030724df26a6b57370d8ea4958a3cf8d3a40b23458ed3b2dd32505
|
||||
34728307b2054d6a494040f0523caf6f62b96c0f67f5b5e9a7e4508370e79419
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
266430921ca7b2271f452c2e8bc4893bd5c1d1516f8081a4ab09950ede9a586b
|
||||
c8de2df9607fef2a7e75023e78db06ac83de8598a43632b684756d4e0df6b51b
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
91f3f03a81acd298b26c539003a96d0e87a88139a2ec6b9ade85e9ca2e89d1a1
|
||||
2523894a6fb5a75d29429ab096fdd9acf150c05eb4ea13c9e92920c8d8ca4459
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
e7cdaeb4f482f698026542c1545f3a1f4734f78f7e9ca0a83cdbc805669c2c7c
|
||||
b534a088809e38ff210ed169b6968f3e793fa0fdcb12d022d2bcee5a58834514
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
ebdcbc36896ab0d545946a95befbc5aeb2b127d615c4754b0077975c53ee0286
|
||||
145bad8868c73368d4e83087861293fad309aaa87263bb12508a6acf693c25da
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
786fd1808d786dd6abd2671b567db1e37075c08610ac4715c93c0624dbe396d6
|
||||
f2f4a7d3fbe4b86369e35969782dd66150df6ae4451e375122d3c1b232caeac1
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
500b934eafbe02493b8d2b42f779281edd00b899f70ed8732de3edc377ceca63
|
||||
c29594e55d321ee5240f8f61223d72c9095695a5b19ff1f8a25c5a6402a4a736
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
2ab43c67c74bd9f8d8ef788b8dca2f6f7288e79ad53663dc51624f36730e52f7
|
||||
01c6f9e9b1e76b3328294f6a31ba4d8f3545d04a4fa56cc8069c59aa37a13f27
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
db60e5fccb72de283a772176052bf659a4ebff11bfb93f634dd513ec878837a7
|
||||
745e791fe2fde2b4d7b58b40a33a8037999aac947865a8f4a5127021b7f16156
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
fa82a8596bd6505c769c0125ea26cef9af08535b36f650ddc4a353eb50866cea
|
||||
4fff5b261e8255aa2dac8342694dee129ea4a8c9520105c719d0c16a16846517
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user