feat(plugins): expose indexed workspace symbol search (#2040)

Closes #1944

API 1.3 lets a plugin search the IDE's workspace symbol index. Results are limited to paths the plugin may read under its `project.read` grant (external content roots and symlink aliases excluded), the search honours the call's cancellation and deadline including lock waits, and ranges use UTF-16. Incremental index updates no longer publish symlink-ancestor aliases. Verified on macOS with the test fixture.

Co-authored-by: arnaud-ritti <[email protected]>
This commit is contained in:
Arnaud Ritti authored and GitHub committed 2026-11-08 03:15:08 +08:00
1 parent 0bee9b7aab
commit 7cedf0b9be
71 files changed
+1157 -40

No files matched your search

+82
View File
@@ -51,6 +51,17 @@ pub trait PluginWorkspace: Send + Sync {
fn indexed_files(&self, _root: &Path, _matches: &dyn Fn(&Path) -> bool, _limit: usize) -> Option<Vec<PathBuf>> {
None
}
/// Current index of the project rooted at `root`; never reads disk or calls LSP.
fn symbols(
&self,
_root: &Path,
_query: &str,
_limit: usize,
_cancel: &CancellationToken,
_deadline: Instant,
) -> Result<Vec<ide_plugin::SymbolInfo>, HostError> {
Err(HostError::NotFound("no project index is available".into()))
}
/// Version and text of the open document at `path`.
fn document(&self, path: &Path) -> Option<(u64, String)>;
/// Applies `edits` to the open document at `path` if it is still at
@@ -1297,6 +1308,26 @@ impl HostApi for PluginApi {
}
}
fn symbols(
&self,
root: &Path,
query: &str,
limit: usize,
cancel: &CancellationToken,
deadline: Instant,
) -> Result<Vec<ide_plugin::SymbolInfo>, HostError> {
if query.len() > 256 {
return Err(HostError::Invalid("symbol query exceeds 256 UTF-8 bytes".into()));
}
self.shared.workspace().ok_or_else(|| HostError::NotFound("no project index is available".into()))?.symbols(
root,
query,
limit.min(1000),
cancel,
deadline,
)
}
fn find_files(&self, root: &Path, glob: &str, limit: usize) -> Result<Vec<String>, HostError> {
let matcher = globset::GlobBuilder::new(glob)
.literal_separator(true)
@@ -1872,6 +1903,57 @@ impl PluginWorkspace for HostWorkspace {
project.index.files_matching(root, matches, limit)
}
fn symbols(
&self,
root: &Path,
query: &str,
limit: usize,
cancel: &CancellationToken,
deadline: Instant,
) -> Result<Vec<ide_plugin::SymbolInfo>, HostError> {
let project = HostWorkspace::projects(self)
.into_iter()
.find(|p| p.root == root)
.ok_or_else(|| HostError::NotFound("the project is no longer open".into()))?;
let stopped = || cancel.is_cancelled() || Instant::now() >= deadline;
let interrupted = || {
if cancel.is_cancelled() {
HostError::Cancelled
} else {
HostError::Failed("symbol search timed out".into())
}
};
let symbols = project
.index
.find_symbols_until(
query,
ide_index::SymbolFilter::All,
limit.min(1000),
&|path| path.starts_with(root),
&stopped,
)
.ok_or_else(interrupted)?;
let mut out = Vec::new();
for s in symbols {
if stopped() {
return Err(interrupted());
}
out.push(ide_plugin::SymbolInfo {
name: s.name,
kind: serde_json::to_value(s.kind)
.ok()
.and_then(|k| k.as_str().map(str::to_owned))
.unwrap_or_else(|| "unknown".into()),
container: s.container,
location: ide_plugin::Location { path: s.path.0, range: s.range },
});
}
if stopped() {
return Err(interrupted());
}
Ok(out)
}
fn document(&self, path: &Path) -> Option<(u64, String)> {
let (project, doc) = self.open_document(path)?;
let snap = project.documents.snapshot(doc).ok()?;
+232
View File
@@ -0,0 +1,232 @@
//! The synchronous workspace import uses the real project index, also from a provider.
use ide_core::CancellationToken;
use ide_core::{NullSink, Pos, Range};
use ide_host::plugins::{Consent, HostWorkspace, PluginHost, PluginWorkspace};
use ide_host::{Host, HostConfig};
use ide_plugin::{Invocation, Limits, ProviderDocument};
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
const WAIT: Duration = Duration::from_secs(30);
struct Fixture {
_tmp: tempfile::TempDir,
_runtime: tokio::runtime::Runtime,
host: Arc<Host>,
plugins: PluginHost,
root: PathBuf,
other: PathBuf,
}
impl Fixture {
fn new(capability: bool) -> Self {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let (root, other) = (base.join("project"), base.join("other"));
for dir in [&root, &other] {
std::fs::create_dir_all(dir).unwrap();
}
std::fs::write(root.join("Order.php"), "<?php class OrderController { public function item() {} }\n").unwrap();
std::fs::write(root.join("Main.java"), "class OrderService { void serve() {} }\n").unwrap();
std::fs::write(other.join("Order.php"), "<?php class OrderElsewhere {}\n").unwrap();
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();
let syntax = OnceLock::new();
syntax.set(Arc::new(ide_language::Syntax::new())).ok().unwrap();
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::new(syntax),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: runtime.handle().clone(),
project_environment: false,
}));
// Restricted projects need no LSP/process startup for index queries.
{
let _guard = runtime.enter();
for (window, dir) in [("main", &root), ("other", &other)] {
let project = host.open_project(window, dir).unwrap();
assert!(project.index.wait_idle(WAIT));
}
}
let plugins = PluginHost::new(
base.join("plugins"),
Arc::new(|_: Option<&str>, _| {}),
Limits { edit_soft: Duration::from_secs(5), edit_hard: Duration::from_secs(10), ..Limits::default() },
)
.unwrap();
plugins.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host)))));
plugins.set_project_policy(Arc::new(|_| true));
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/symbols");
let package = base.join("fixture");
std::fs::create_dir_all(package.join("dist")).unwrap();
std::fs::copy(source.join("dist/plugin.wasm"), package.join("dist/plugin.wasm")).unwrap();
let manifest = std::fs::read_to_string(source.join("plugin.toml")).unwrap();
let manifest =
if capability { manifest } else { manifest.replace("required = [\"project.read\"]", "required = []") };
std::fs::write(package.join("plugin.toml"), manifest).unwrap();
plugins.install_from_dir(&package, &Consent::all()).unwrap();
Self { _tmp: tmp, _runtime: runtime, host, plugins, root, other }
}
fn invoke(&self, command: &str, root: &Path, query: &str) -> ide_core::IdeResult<Option<String>> {
self.plugins.run_command(
"test.symbols",
command,
Some(query),
Invocation { workspace_root: Some(root.to_path_buf()), ..Default::default() },
)
}
fn search(&self, query: &str) -> String {
self.invoke("symbols", &self.root, query).unwrap().unwrap()
}
}
impl Drop for Fixture {
fn drop(&mut self) {
self.host.close_project("main");
self.host.close_project("other");
}
}
#[test]
fn symbols_reach_commands_and_providers_without_a_language_server() {
let f = Fixture::new(true);
let all = f.search("Order|10");
assert!(all.contains("OrderController\tclass\t"), "{all}");
assert!(all.contains("OrderService\tclass\t"), "{all}");
assert!(!all.contains("OrderElsewhere"));
assert_eq!(f.search("OCo|1").lines().count(), 1);
assert!(f.search("OrderController.item|1").starts_with("item\tmethod\tOrderController\t"));
assert!(f.search("|10").is_empty());
assert!(f.search("Order|0").is_empty());
assert!(f.search("missing|10").is_empty());
assert!(f.search("Order|4294967295").contains("OrderController"));
assert!(
f.invoke("symbols", &f.root, &format!("{}|1", "é".repeat(129)))
.unwrap_err()
.message
.contains("256 UTF-8 bytes")
);
let other = f.invoke("symbols", &f.other, "Order|10").unwrap().unwrap();
assert!(other.contains("OrderElsewhere") && !other.contains("OrderController"));
for language in ["php", "java"] {
let targets = f.plugins.provide_definition(
&ProviderDocument {
path: f.root.join(if language == "php" { "Usage.php" } else { "Usage.java" }),
language: language.into(),
version: 1,
text: "OCo".into(),
},
Pos::new(0, 3),
);
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].path, f.root.join("Order.php"));
let indexed = HostWorkspace(Arc::downgrade(&f.host))
.symbols(&f.root, "OCo", 1, &CancellationToken::new(), Instant::now() + WAIT)
.unwrap();
assert_eq!(targets[0].range, indexed[0].location.range);
}
}
#[test]
fn reindex_and_closed_project_replace_results_and_invalidate_retained_handles() {
let f = Fixture::new(true);
f.invoke("remember", &f.root, "").unwrap();
let p = f.host.project("main").unwrap();
let path = f.root.join("Order.php");
std::fs::write(&path, "<?php /*😀*/ class UpdatedController {}\n").unwrap();
p.index.update(std::slice::from_ref(&path), &[]);
assert!(p.index.wait_idle(WAIT));
assert!(!f.search("Order|10").contains("OrderController"));
let symbols = HostWorkspace(Arc::downgrade(&f.host))
.symbols(&f.root, "UpdatedController", 1, &CancellationToken::new(), Instant::now() + WAIT)
.unwrap();
assert_eq!(symbols.len(), 1);
assert_eq!(symbols[0].location.range, Range::new(Pos::new(0, 19), Pos::new(0, 36)));
assert!(f.search("Updated|10").contains("0:19-0:36"));
std::fs::remove_file(&path).unwrap();
p.index.update(&[], &[path]);
assert!(p.index.wait_idle(WAIT));
assert!(f.search("Updated|10").is_empty());
f.host.close_project("main");
let error = f.invoke("remembered", &f.root, "Order|10").unwrap_err();
assert!(error.message.contains("no longer open"), "{error}");
}
#[test]
fn large_limits_are_capped_before_searching_the_index() {
let f = Fixture::new(true);
let project = f.host.project("main").unwrap();
let path = f.root.join("Bulk.java");
let text = (0..1005).map(|i| format!("class BulkSymbol{i} {{}}\n")).collect::<String>();
std::fs::write(&path, text).unwrap();
project.index.update(&[path], &[]);
assert!(project.index.wait_idle(WAIT));
assert_eq!(f.search("BulkSymbol|4294967295").lines().count(), 1000);
}
#[test]
fn symbol_queries_require_project_read_even_with_an_invocation_project() {
let f = Fixture::new(false);
let error = f.invoke("symbols", &f.root, "Order|10").unwrap_err();
assert!(error.message.contains("project.read"), "{error}");
assert!(
f.plugins
.provide_definition(
&ProviderDocument {
path: f.root.join("Usage.php"),
language: "php".into(),
version: 1,
text: "OCo".into(),
},
Pos::new(0, 3)
)
.is_empty()
);
}
#[test]
fn symbol_search_excludes_external_content_roots_before_ranking() {
let f = Fixture::new(true);
let project = f.host.project("main").unwrap();
std::fs::write(f.other.join("Exact.php"), "<?php class Order {}\n").unwrap();
project.index.set_scope(vec![f.other.clone()], Vec::new());
assert!(project.index.wait_idle(WAIT));
assert_eq!(project.index.find_symbols("OrderElsewhere", ide_index::SymbolFilter::All, 1).len(), 1);
assert!(f.search("OrderElsewhere|10").is_empty());
// A better external match must not displace a readable fuzzy match.
let result = f.search("Order|1");
assert!(result.contains("OrderController") || result.contains("OrderService"), "{result}");
}
#[test]
fn native_symbol_search_honours_cancellation_and_deadlines() {
let f = Fixture::new(true);
let workspace = HostWorkspace(Arc::downgrade(&f.host));
let cancel = CancellationToken::new();
cancel.cancel();
assert_eq!(
workspace.symbols(&f.root, "Order", 10, &cancel, Instant::now() + WAIT),
Err(ide_plugin::HostError::Cancelled)
);
let error = workspace.symbols(&f.root, "Order", 10, &CancellationToken::new(), Instant::now()).unwrap_err();
assert!(error.to_string().contains("timed out"), "{error}");
}
#[cfg(unix)]
#[test]
fn symbol_search_excludes_updates_through_escaping_symlink_ancestors() {
let f = Fixture::new(true);
let project = f.host.project("main").unwrap();
std::fs::write(f.other.join("Secret.php"), "<?php class SecretSymbol {}\n").unwrap();
let dir = f.root.join("linked");
std::os::unix::fs::symlink(&f.other, &dir).unwrap();
project.index.update(&[dir.join("Secret.php")], &[]);
assert!(project.index.wait_idle(WAIT));
assert!(f.search("Secret|1").is_empty());
}
+37
View File
@@ -268,6 +268,43 @@ impl ProjectIndex {
matching::find_symbols(&d, &matching::Recents { paths: &recent }, query, filter, limit)
}
/// Scoped symbol search with cooperative interruption, including lock waits.
/// `None` means interrupted; no partial results are returned.
pub fn find_symbols_until(
&self,
query: &str,
filter: SymbolFilter,
limit: usize,
include: &(impl Fn(&Path) -> bool + Sync),
stopped: &(impl Fn() -> bool + Sync),
) -> Option<Vec<SymbolMatch>> {
let recent = loop {
if stopped() {
return None;
}
if let Some(recent) = self.shared.recent.try_lock_for(Duration::from_millis(1)) {
break recent.clone();
}
};
let data = loop {
if stopped() {
return None;
}
if let Some(data) = self.shared.data.try_read_for(Duration::from_millis(1)) {
break data;
}
};
matching::find_symbols_until(
&data,
&matching::Recents { paths: &recent },
query,
filter,
limit,
include,
stopped,
)
}
/// Symbols named exactly `name` (of `kind`, if given): `.card` selectors for `class="card"`.
pub fn symbols_named(&self, name: &str, kind: Option<SymbolKind>, limit: usize) -> Vec<SymbolMatch> {
matching::symbols_named(&self.shared.data.read(), name, kind, limit)
+44 -10
View File
@@ -121,22 +121,35 @@ fn scan(
tiebreak: &(impl Fn(&Cand, &Cand) -> Ordering + Sync),
per_file: impl Fn(u32, &FileEntry, &mut Vec<Cand>) + Sync,
) -> Vec<Cand> {
scan_until(data, pool, tiebreak, &|| false, per_file).unwrap_or_default()
}
fn scan_until(
data: &IndexData,
pool: usize,
tiebreak: &(impl Fn(&Cand, &Cand) -> Ordering + Sync),
stopped: &(impl Fn() -> bool + Sync),
per_file: impl Fn(u32, &FileEntry, &mut Vec<Cand>) + Sync,
) -> Option<Vec<Cand>> {
data.files
.par_iter()
.enumerate()
.with_min_len(256)
.fold(Vec::new, |mut acc, (slot, e)| {
.try_fold(Vec::new, |mut acc, (slot, e)| {
if stopped() {
return None;
}
if let Some(e) = e {
per_file(slot as u32, e, &mut acc);
if acc.len() >= pool * 2 {
acc = keep_best(acc, pool, tiebreak);
}
}
acc
Some(acc)
})
.reduce(Vec::new, |mut a, b| {
.try_reduce(Vec::new, |mut a, b| {
a.extend(b);
if a.len() >= pool * 2 { keep_best(a, pool, tiebreak) } else { a }
Some(if a.len() >= pool * 2 { keep_best(a, pool, tiebreak) } else { a })
})
}
@@ -423,9 +436,21 @@ pub(crate) fn find_symbols(
filter: SymbolFilter,
limit: usize,
) -> Vec<SymbolMatch> {
find_symbols_until(data, recents, query, filter, limit, &|_| true, &|| false).unwrap_or_default()
}
pub(crate) fn find_symbols_until(
data: &IndexData,
recents: &Recents,
query: &str,
filter: SymbolFilter,
limit: usize,
include: &(impl Fn(&Path) -> bool + Sync),
stopped: &(impl Fn() -> bool + Sync),
) -> Option<Vec<SymbolMatch>> {
let q: String = query.trim().chars().filter(|c| !c.is_whitespace()).collect();
if q.is_empty() || limit == 0 {
return Vec::new();
return Some(Vec::new());
}
let (container_q, name_q) = split_symbol_query(&q);
let name = Needle::new(name_q);
@@ -436,13 +461,16 @@ pub(crate) fn find_symbols(
sa.name.len().cmp(&sb.name.len()).then_with(|| file(data, a.slot).rel.cmp(&file(data, b.slot).rel))
};
let pool = limit * 4 + 64;
let cands = scan(data, pool, &tiebreak, |slot, e, acc| {
if e.symbols.is_empty() {
let cands = scan_until(data, pool, &tiebreak, stopped, |slot, e, acc| {
if e.symbols.is_empty() || !include(&e.path) {
return;
}
with_matcher(|m| {
let mut buf = Vec::new();
for (i, s) in e.symbols.iter().enumerate() {
if stopped() {
break;
}
if filter == SymbolFilter::Types && !s.kind.is_type_like() {
continue;
}
@@ -451,11 +479,15 @@ pub(crate) fn find_symbols(
}
}
});
});
})?;
if stopped() {
return None;
}
let mut out: Vec<(u32, Cand, Vec<u32>)> = with_matcher(|m| {
let mut buf = Vec::new();
cands
.into_iter()
.take_while(|_| !stopped())
.filter_map(|c| {
let s = sym(&c);
let e = file(data, c.slot);
@@ -472,7 +504,8 @@ pub(crate) fn find_symbols(
b.0.cmp(&a.0).then_with(|| tiebreak(&a.1, &b.1)).then_with(|| sym(&a.1).range.cmp(&sym(&b.1).range))
});
out.truncate(limit);
out.into_iter()
let results = out
.into_iter()
.map(|(score, c, indices)| {
let e = file(data, c.slot);
let s = &e.symbols[c.sym as usize];
@@ -487,7 +520,8 @@ pub(crate) fn find_symbols(
indices,
}
})
.collect()
.collect();
(!stopped()).then_some(results)
}
/// Symbols named exactly `name` (and of `kind`, when given), by path then position.
+77
View File
@@ -652,3 +652,80 @@ fn plugin_inventory_respects_excludes_and_symbols_require_matching_text() {
assert!(symbols.iter().any(|s| s.name == "find"));
assert!(index.symbols_for_text(&path, &java.replace("find", "renamed")).is_none());
}
#[test]
fn symbol_search_can_stop_during_scanning_and_lock_waits() {
use std::sync::atomic::AtomicUsize;
let fx = sample();
let idx = fx.open();
let calls = AtomicUsize::new(0);
let stopped = || calls.fetch_add(1, Ordering::Relaxed) >= 5;
assert!(idx.find_symbols_until("a", SymbolFilter::All, 20, &|_| true, &stopped).is_none());
assert!(calls.load(Ordering::Relaxed) > 5);
for recent in [false, true] {
let _data = (!recent).then(|| idx.shared.data.write());
let _recent = recent.then(|| idx.shared.recent.lock());
let deadline = std::time::Instant::now() + Duration::from_millis(10);
assert!(
idx.find_symbols_until("a", SymbolFilter::All, 20, &|_| true, &|| std::time::Instant::now() >= deadline)
.is_none()
);
}
}
#[test]
fn scoped_symbol_search_preserves_fuzzy_ranking_within_allowed_paths() {
let fx = sample();
let idx = fx.open();
let allowed = fx.root.join("src/order");
let symbols =
idx.find_symbols_until("OCo", SymbolFilter::All, 1, &|path| path.starts_with(&allowed), &|| false).unwrap();
assert_eq!(symbols.len(), 1);
assert_eq!(symbols[0].name, "OrderController");
assert!(
idx.find_symbols_until("OtherCollection", SymbolFilter::All, 1, &|path| path.starts_with(&allowed), &|| false)
.unwrap()
.is_empty()
);
}
#[cfg(unix)]
#[test]
fn incremental_symbols_resolve_symlink_ancestors_before_scope_checks() {
let fx = Fixture::new(&[("src/inside.rs", "fn inside_symbol() {}\n")]);
let outside = fx._dir.path().join("outside");
std::fs::create_dir(&outside).unwrap();
std::fs::write(outside.join("secret.rs"), "fn secret_symbol() {}\n").unwrap();
std::os::unix::fs::symlink(&outside, fx.root.join("escape")).unwrap();
std::os::unix::fs::symlink(fx.root.join("src"), fx.root.join("alias")).unwrap();
let idx = fx.open();
idx.update(&[fx.root.join("escape/secret.rs"), fx.root.join("alias/inside.rs")], &[]);
assert!(idx.wait_idle(WAIT));
assert!(symbols(&idx, "secret_symbol", SymbolFilter::All).is_empty());
let inside = idx.find_symbols("inside_symbol", SymbolFilter::All, 10);
assert_eq!(inside.len(), 1);
assert_eq!(inside[0].path.0, fx.root.join("src/inside.rs"));
}
#[cfg(unix)]
#[test]
fn persisted_symbols_do_not_load_through_escaping_symlink_ancestors() {
let fx = Fixture::new(&[("src/old.rs", "fn old_symbol() {}\n")]);
drop(fx.open());
let outside = fx._dir.path().join("outside");
std::fs::rename(fx.root.join("src"), &outside).unwrap();
std::os::unix::fs::symlink(outside, fx.root.join("src")).unwrap();
let initial = Arc::new(Mutex::new(Vec::new()));
let events = initial.clone();
let sink: SharedSink = Arc::new(move |event: IdeEvent| {
if let IdeEvent::IndexState { symbols, .. } = event {
events.lock().push(symbols);
}
});
let languages = Arc::new(LanguageRegistry::load(None).unwrap());
let idx = ProjectIndex::open(&fx.root, &fx.cache, Vec::new(), Vec::new(), languages, Arc::new(Syntax::new()), sink)
.unwrap();
assert!(idx.wait_idle(WAIT));
assert!(symbols(&idx, "old_symbol", SymbolFilter::All).is_empty());
assert_eq!(initial.lock()[0], 0, "unsafe cached symbols must never be published");
}
+8 -2
View File
@@ -174,7 +174,10 @@ impl Shared {
Ok(records) => {
let entries: Vec<FileEntry> = records
.into_par_iter()
.filter(|(rel, _)| !filter.is_excluded(&root.join(rel), false))
.filter(|(rel, _)| {
let path = root.join(rel);
!filter.is_excluded(&path, false) && dunce::canonicalize(&path).is_ok_and(|real| real == path)
})
.map(|(rel, rec)| FileEntry::new(root.join(&rel), rel, rec))
.collect();
let mut d = self.data.write();
@@ -420,7 +423,10 @@ impl Shared {
let mut seen = HashSet::new();
let mut by_dir: HashMap<PathBuf, HashSet<PathBuf>> = HashMap::new();
for path in changed {
if !seen.insert(path.clone()) {
// A watcher path may have a symlink ancestor even when its spelling
// is inside the root. Resolve it on the worker before reading it.
let Ok(path) = dunce::canonicalize(&path) else { continue };
if !filter.contains(&path) || !seen.insert(path.clone()) {
continue;
}
let Ok(meta) = std::fs::symlink_metadata(&path) else { continue };
+13
View File
@@ -122,6 +122,19 @@ pub trait HostApi: Send + Sync {
.filter(|root| real.starts_with(crate::resolve_real(root)))
.max_by_key(|root| root.components().count())
}
/// Searches the current in-memory index of an open project.
fn symbols(
&self,
root: &Path,
query: &str,
limit: usize,
cancel: &CancellationToken,
deadline: Instant,
) -> Result<Vec<crate::SymbolInfo>, HostError> {
let _ = (root, query, limit, cancel, deadline);
Err(HostError::NotFound("no project index is available".into()))
}
/// Unsaved text of an open editor's file.
fn buffer_text(&self, path: &Path) -> Option<String>;
/// Version and text of the open document at `path`.
+9
View File
@@ -433,6 +433,15 @@ pub struct Location {
pub range: ide_core::Range,
}
/// A symbol in the current project index. Ranges use UTF-16 columns.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SymbolInfo {
pub name: String,
pub kind: String,
pub container: Option<String>,
pub location: Location,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CompletionItem {
pub label: String,
+33
View File
@@ -511,6 +511,39 @@ impl workspace::Host for StoreData {
self.host.project_of(Path::new(&path)).map(|root| self.push(ProjectRes { root })).transpose()
}
fn symbols(
&mut self,
project: Resource<ProjectRes>,
query: String,
limit: u32,
) -> Fallible<Vec<workspace::SymbolInfo>> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
if query.len() > 256 {
return Ok(Err(wit::Error::Invalid("symbol query exceeds 256 UTF-8 bytes".into())));
}
let root = &self.table.get(&project)?.root;
Ok(self
.host
.symbols(root, &query, limit.min(1000) as usize, &self.cancel.token(), self.deadline)
.map(|symbols| {
symbols
.into_iter()
.map(|s| workspace::SymbolInfo {
name: s.name,
kind: s.kind,
container: s.container,
location: wit::Location {
path: to_ipc(&s.location.path),
range: range_to_wit(&s.location.range),
},
})
.collect()
})
.map_err(to_wit))
}
fn projects(&mut self) -> Fallible<Vec<Resource<ProjectRes>>> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
+1
View File
@@ -31,6 +31,7 @@ fn components() -> Vec<PathBuf> {
repo().join("crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm"),
repo().join("crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm"),
repo().join("crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm"),
repo().join("crates/ide-plugin/tests/fixtures/symbols/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();
Binary file not shown.
@@ -1 +1 @@
13db3ba9045429fef5973e83075a51a315076408a03ec3c1b837c0229365fcbe
3f444f1cc3b7f495f26378594fd9f98418146cd8b2275310e53d4905d0bb5c30
Binary file not shown.
@@ -1 +1 @@
6c2a59bf732acebd74ad4d05824be3aafbd501964be01205ca1e0d5a6135d113
26ae74bb9df6f88bca87a31d20fb00d52cd9464047df3525d0e47b5bf6b2b2ca
Binary file not shown.
@@ -1 +1 @@
183bf50a6ee479128440ef4bd86939f24b036b699a24842a6efd1d3f43c6cd10
24b3c81c74355174aa2fd484677bbac38020ecd119aff419d0479a942e2e4dbd
Binary file not shown.
@@ -1 +1 @@
97f7f6fe965e95510d5556feb40329c312e5430e631479be6c9646046a888478
0bb069928979ba22a2d6bd977108d905ff870ef981aad6ef4930834ad5c35c35
Binary file not shown.
@@ -1 +1 @@
f73de3fdfe5b343f26ff4dd1ae150c975273ce398d518f6f2100b0baa8074ffc
da7c5e58d2d26b7613a8ba5b50d4ec7788438f186b4abcfda90a9bf0c34ab591
Binary file not shown.
@@ -1 +1 @@
a985162e44066c6bf6c93d01af1b01d40d305c6be43072d4f33d4f1f19a4dee0
65583ded79c9fb4de0ed856d04aa08e58c69368d3a9235a57afa50ea757eb45f
Binary file not shown.
@@ -1 +1 @@
5063a997e11258386e88ba129d42262f5050bfba41c8ea069152b2dfba007fa7
0c0e4f3611971307268cc8e2cc6fb96734923f10dbccbd8950639d86036c682c
Binary file not shown.
@@ -1 +1 @@
9a3d097559f6685e82024bd84cb0396875762b7136636fdc9bd11cb41875373a
c09efb8c9d0be262b7a25099639213e304fbfd5b0b91ad3d80672fb8148c0e4a
Binary file not shown.
@@ -1 +1 @@
6ca0c03e31e0cd67192bb86c93b1feb294fec79cf432ceb57b9cdc3e42eb6b02
7c728e98b1e1d4ea3d03831c263298fc4792599a81138e40dfd4c48ce6c4a47e
@@ -1 +1 @@
a3b813b172441c5fad462b249c69dadda2b8fb1e2b387bda2487f7df3f729338
d8626558aba036e4d476df9929c6e16089dd0a1e43101b48dd978946889c7616
Binary file not shown.
@@ -1 +1 @@
848356d2e0fbaef341436ea70a4698915cce705d3ccea4dd4e50900b12c97d33
512b7d41868034322fccc15f100c85e9f53335c22aec4d7492a14723b54cd874
Binary file not shown.
@@ -1 +1 @@
07001ff22cd33d5ae6f96b584707e66d31f2f93423cf72a993e6f11c9f0ec319
de7a217d3931c62387718b231a2c895438a210cc2e2b2cbb374312a1cf86a68d
Binary file not shown.
@@ -1 +1 @@
ae9cb4a106aa8fd46662dba07acd0daba2927b7fe53e544cd95348954505bc36
f76dd5cbd131af57c371c2f44146274426731a3e9609cc6f5aaabbacaecb17a7
+333
View File
@@ -0,0 +1,333 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "macro-string"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "openidea-plugin"
version = "0.1.0"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "openidea-test-symbols"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
[[package]]
name = "prettyplease"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-encoder"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
dependencies = [
"bitflags",
"hashbrown",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
dependencies = [
"anyhow",
"macro-string",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.262.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
dependencies = [
"anyhow",
"hashbrown",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-ident",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "openidea-test-symbols"
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"
Binary file not shown.
@@ -0,0 +1 @@
ffe6e931db4d0232005de934fce7090ae9098e9cdfd1f9e8b7f6243abe3d3ae9
+23
View File
@@ -0,0 +1,23 @@
id = "test.symbols"
name = "Indexed symbols fixture"
version = "0.1.0"
engines.openidea = "^1.3"
wasm = "dist/plugin.wasm"
[capabilities]
required = ["project.read"]
[[contributes.providers]]
kinds = ["definition"]
languages = ["php", "java"]
[[contributes.commands]]
id = "symbols"
title = "Query indexed symbols"
[[contributes.commands]]
id = "remember"
title = "Remember project"
[[contributes.commands]]
id = "remembered"
title = "Query remembered project"
+55
View File
@@ -0,0 +1,55 @@
use openidea_plugin::host::{self, Project};
use openidea_plugin::providers::{Document, Providers};
use openidea_plugin::{Error, Invocation, Location, Plugin, Position, register_plugin};
#[derive(Default)]
struct Fixture {
project: Option<Project>,
}
impl Plugin for Fixture {
fn run_command(&mut self, id: &str, ctx: &Invocation, arg: Option<&str>) -> Result<Option<String>, Error> {
if id == "remember" {
self.project = ctx.project();
return Ok(None);
}
let current = ctx.project();
let project = if id == "remembered" { self.project.as_ref() } else { current.as_ref() }
.ok_or_else(|| Error::NotFound("no project".into()))?;
let (query, limit) = arg.unwrap_or("Order|10").rsplit_once('|').ok_or("expected query|limit")?;
let limit = limit.parse().map_err(|_| Error::Invalid("invalid limit".into()))?;
let symbols = project.symbols(query, limit)?;
Ok(Some(
symbols
.into_iter()
.map(|s| {
format!(
"{}\t{}\t{}\t{}\t{}:{}-{}:{}",
s.name,
s.kind,
s.container.unwrap_or_default(),
s.location.path,
s.location.range.start.line,
s.location.range.start.character,
s.location.range.end.line,
s.location.range.end.character
)
})
.collect::<Vec<_>>()
.join("\n"),
))
}
}
impl Providers for Fixture {
fn definition(&mut self, doc: &Document, _: Position) -> Vec<Location> {
host::project_of(&doc.path)
.and_then(|project| project.symbols(doc.text.trim(), 10).ok())
.unwrap_or_default()
.into_iter()
.map(|s| s.location)
.collect()
}
}
register_plugin!(Fixture, providers);
Binary file not shown.
@@ -1 +1 @@
8a79f2899d88773d7c33175136960727b5ad0aefc5ce5eb363fd20dc4990c60e
8d42a25cd250041ceb6aaafed66ac0bd38ff1717cc450d6175517cd3d01738c9
+16 -1
View File
@@ -281,7 +281,7 @@ machine. Users can revoke optional capabilities in Settings › Plugins.
| Capability | Risk | Unlocks today |
| ------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------- |
| `project.read` | low | `Project::root`, `read_file`, `find_files`, `host::projects`, `publish_diagnostics` |
| `project.read` | low | `Project::root`, `read_file`, `find_files`, `symbols`, `host::projects`, `publish_diagnostics` |
| `project.write` | medium | `Project::write_file` — creates and replaces text files inside the open projects |
| `fs.read:<path>` | high | `host::fs::read_file`, `read_bytes`, `stat`, `read_dir` — files outside the projects, under the grant's directory |
| `fs.write:<path>` | high | `host::fs::write_file`, `write_bytes` — files under that path; doesn't imply reading |
@@ -439,6 +439,7 @@ then run `openidea-plugin check`.
| `host::locale()` | `ui.locale` | The IDE's UI language as a BCP 47 tag (`en`, `fr`, `zh-CN`); no capability. See _Translations_. |
| `host::log(level, msg)` | `log` | IDE log, tagged with the plugin id. `eprintln!` output is logged too and shown when the plugin crashes. |
| `Project::find_files(glob, limit)` | `workspace.project.find-files` | Project-relative glob (`**/*Mapper.xml`); honours `.gitignore`; at most `limit` paths. |
| `Project::symbols(query, limit)` | `workspace.symbols` | New in 1.3; `project.read`. Fuzzy in-memory search (no LSP): name, camelCase kind, container and UTF-16 location. Queries ≤ 256 UTF-8 bytes; limits capped at 1000. Snapshot notes below. |
| `host::publish_diagnostics(path, items)` | `diagnostics.publish` | Replaces this plugin's problems for the file (editor squiggles and the Problems view); an empty list clears them. |
| `host::fetch(request)` → `{status, headers, body}` | `net.fetch` | See _Network_. |
| `Project::run_process(program, args, stdin)` → `{code, stdout, stderr}` | `process.run` | Only the pinned program of a `process:` grant; runs in the project root of a trusted project; 4 MiB of output; killed when the call is cancelled or times out. |
@@ -451,6 +452,20 @@ then run `openidea-plugin check`.
| `host::open_location(path, line, column)` | `views.open-location` | Opens a file inside the project in an editor of the invoking window, the caret at `line`/`column` (0-based, UTF-16): a view's links to code. Needs `project.read`. |
| `host::setting(key)`, `setting_bool`, `setting_string`, `setting_number` | `settings.get` | The user's value (project over global) or the schema's default. |
`Project::symbols` reads the current in-memory index: results may be partial
while indexing, and locations can lag unsaved edits. Normal saves and filesystem
changes update the index in the background. Results are confined to the project's
root, with normal exclusions; external content roots and updates through escaping
symlinks are excluded. Search honours the current call's cancellation and deadline.
Empty queries and zero limits return no symbols; a closed project returns
`Error::NotFound`.
Type resolution and member queries remain deferred to the asynchronous API 2.0
model. Providers can select their project with `host::project_of(&doc.path)` and
call `symbols` synchronously; `crates/ide-plugin/tests/fixtures/symbols` demonstrates
a definition provider. `MockHost::set_symbols(root, symbols)` replaces a test
snapshot; its mock uses case-insensitive substring matching rather than fuzzy ranking.
Both process APIs only add environment variables. A key already in the prepared child
environment (including the login-shell `PATH`) fails with an error naming the key;
variables are never replaced or removed. Names compare case-insensitively on Windows.
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
7872480dee517db617120a9327d3bbbcc007d31bbf9e6c999ce202f38f0ed478
977835f3364613876b3f563877d7f0cf36d668226c5c6ac2280473c73d44117d
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
31f1d88c7e88553eed145fd170485c10e19967eccbf2021ec71fca862d05e952
55e3306fc2ff9456d79f02b63712c7f83b1fe4e75fec051f5fb96e7fb766066f
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
6a2db880c49b15d7ce5ba7fda69ac8d1cac04f0412d42b6922550594ef94a7c5
f582b640e980e4b69e292ec425d0ed8113527119d18d0fa1ea73e7c34f1f89a1
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
9299f10c517ee5582312f0f14871e21d36050ee8d621df31d7e6a4126c4ea37e
842de029af0767864b56dc71199a823ca0ea04aec5ece8928dac69340ff7ab7a
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
1f86f15f5e864ec9a91cb31379047eafd4ae8c07ffacb5e1c85955ee07873342
1dd92a9e139458813f513479cbc0f8187eff217f5dac2b39441537e3039e9dca
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
540e60e34b752eb201ef9cacea44066b4f59a4e36c3d1a468bf208223569a628
5ce56abe0082fc610deec13f6907cdfd8be7346ce9a1c43c59e1828957bc3274
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
106f4a0b300fdb98fd0bd8f8bc1b3ce2c692a8a7e0107353905d7edbb7c7ce4d
be6dd0b46a0f407f354ae2b67c6304948c71749bc3587dcf81b39dd245afdb48
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
eba583c669da994dba24a8a16546c9d2cb50fb46db53207207809b20882680e8
4fe7aec43c3009cd0567e93f2e80c135091269aa0a2fcea3ba00f43a24c5ec56
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
d1385eb9aa4d78bbcbdf98565fdf3b550050388af9248814f5c994b54d605708
7be4ce274fef38b5d4741646de970afd803fbf5ccffaa49236af1d15c8c870a9
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
8f4c3af7f1f0f6e2001ce161d89e2b83ef700480d6fd5517f46e90437f74639e
0a02c2ec0444e5eccb68b2bfabeaaf7752d1bc2b22bc0f26883a2a35eeac7df7
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
2504128882832c282d627946668f10603fe4b165dca8f12a49131934d1a5c09d
10c214d40a6b43bb8b850bfe6d97c1a360ed748b5e95645354b3317806d7a538
+27
View File
@@ -310,6 +310,22 @@ mod imp {
t.is_cancelled()
}
pub fn project_symbols(p: &ProjectRes, query: &str, limit: u32) -> Result<Vec<SymbolInfo>, Error> {
wit::workspace::symbols(p, query, limit)
.map(|symbols| {
symbols
.into_iter()
.map(|s| SymbolInfo {
name: s.name,
kind: s.kind,
container: s.container,
location: Location { path: s.location.path, range: range_from(s.location.range) },
})
.collect()
})
.map_err(error)
}
pub fn project_find(p: &ProjectRes, glob: &str, limit: u32) -> Result<Vec<String>, Error> {
p.find_files(glob, limit).map_err(error)
}
@@ -611,6 +627,17 @@ impl Project {
imp::project_find(&self.inner, glob, limit)
}
/// Fuzzy search of this project's current in-memory index. Needs `project.read`.
/// Never waits for LSP or reads file contents. Only paths inside this project's
/// root are returned, including when the index has external content roots.
/// Search honours the current call's cancellation and deadline. Empty queries return no symbols;
/// queries over 256 UTF-8 bytes are invalid and limits are capped at 1000.
/// Results may be incomplete during indexing or lag unsaved edits. A closed
/// project returns `Error::NotFound`. Locations use UTF-16 columns.
pub fn symbols(&self, query: &str, limit: u32) -> Result<Vec<SymbolInfo>, Error> {
imp::project_symbols(&self.inner, query, limit)
}
/// Runs the approved `process:<program>` in this project's root.
pub fn run_process(&self, program: &str, args: &[String], stdin: Option<&str>) -> Result<ProcessOutput, Error> {
imp::run_process(program, args, Some(&self.inner), stdin)
+69
View File
@@ -80,6 +80,7 @@ struct State {
capabilities: Vec<String>,
root: Option<String>,
other_projects: Vec<String>,
symbols: BTreeMap<String, Vec<SymbolInfo>>,
files: BTreeMap<String, String>,
/// Files outside every project, for `fs.read` (absolute paths).
@@ -191,6 +192,12 @@ impl MockHost {
self.state.borrow_mut().other_projects.push(root.to_owned());
}
/// Replaces a project's indexed symbols. Filtering in the mock is a
/// case-insensitive substring; the real index also supports fuzzy matching.
pub fn set_symbols(&self, root: &str, symbols: Vec<SymbolInfo>) {
self.state.borrow_mut().symbols.insert(root.into(), symbols);
}
/// A project file `read_file` can return (project-relative or absolute path).
pub fn add_file(&self, path: &str, text: &str) {
self.state.borrow_mut().files.insert(path.to_owned(), text.to_owned());
@@ -818,6 +825,35 @@ pub mod current {
})
}
pub fn project_symbols(p: &ProjectRes, query: &str, limit: u32) -> Result<Vec<SymbolInfo>, Error> {
with(|s| {
require(s, "project.read")?;
if query.len() > 256 {
return Err(Error::Invalid("symbol query exceeds 256 UTF-8 bytes".into()));
}
if !s.root.iter().chain(&s.other_projects).any(|root| *root == p.root) {
return Err(Error::NotFound("the project is no longer open".into()));
}
let query = query.trim().to_lowercase();
Ok(s.symbols
.get(&p.root)
.into_iter()
.flatten()
.filter(|symbol| {
!query.is_empty()
&& symbol.name.to_lowercase().contains(&query)
&& symbol
.location
.path
.strip_prefix(p.root.trim_end_matches('/'))
.is_some_and(|rest| rest.starts_with('/'))
})
.take(limit.min(1000) as usize)
.cloned()
.collect())
})
}
pub fn project_stat(p: &ProjectRes, path: &str) -> Result<FileStat, Error> {
with(|s| {
require(s, "project.read")?;
@@ -1558,6 +1594,39 @@ mod tests {
assert!(matches!(project.stat("x"), Err(Error::Denied(_))));
}
#[test]
fn indexed_symbols_are_bounded_scoped_and_capability_checked() {
let mock = MockHost::install();
let symbol = SymbolInfo {
name: "OrderController".into(),
kind: "class".into(),
container: Some("App".into()),
location: Location {
path: "/project/Order.php".into(),
range: Range::new(Position::new(0, 2), Position::new(0, 17)),
},
};
let mut symbols = vec![symbol.clone(); 1001];
let mut outside = symbol.clone();
outside.location.path = "/project-other/Order.php".into();
symbols.insert(0, outside);
mock.set_symbols("/project", symbols);
mock.add_project("/other");
let project = mock.invocation().project().unwrap();
assert_eq!(project.symbols("order", u32::MAX).unwrap().len(), 1000);
assert_eq!(project.symbols("order", 1).unwrap(), [symbol]);
assert!(project.symbols("", 1).unwrap().is_empty());
assert!(project.symbols("order", 0).unwrap().is_empty());
assert!(matches!(project.symbols(&"é".repeat(129), 1), Err(Error::Invalid(_))));
assert!(host::projects().unwrap()[1].symbols("order", 1).unwrap().is_empty());
mock.set_symbols("/project", Vec::new());
assert!(project.symbols("order", 1).unwrap().is_empty());
mock.set_project_root(None);
assert!(matches!(project.symbols("order", 1), Err(Error::NotFound(_))));
let _mock = mock.with_capabilities(&[]);
assert_eq!(project.symbols("order", 1), Err(Error::Denied("project.read".into())));
}
#[test]
fn versioned_edits() {
let mock = MockHost::install();
+10
View File
@@ -102,6 +102,16 @@ pub struct TextEdit {
pub text: String,
}
/// A symbol from the host's current in-memory project index.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SymbolInfo {
pub name: String,
/// Host symbol kind in camelCase (`class`, `method`, `enumMember`, …).
pub kind: String,
pub container: Option<String>,
pub location: Location,
}
/// An editor's document at one version.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Document {
+21 -1
View File
@@ -1,7 +1,7 @@
package openidea:[email protected];
interface workspace {
use types.{error};
use types.{error, location};
/// Requests File > Open in this command/event's window (or a new window),
/// including the usual project trust prompt. Needs no capability.
@@ -30,6 +30,26 @@ interface workspace {
modified: option<u64>,
}
/// A symbol in the host's current in-memory project index (new in 1.3).
record symbol-info {
name: string,
/// Host symbol kind, in camelCase (`class`, `method`, `enumMember`, …).
kind: string,
container: option<string>,
location: location,
}
/// Fuzzy symbol search (CamelHump and container-qualified queries) over the
/// current index only; never starts or waits for a language server. Results
/// may be incomplete while indexing and ranges reflect the indexed text,
/// not necessarily unsaved edits. Empty queries return no symbols. Queries
/// over 256 UTF-8 bytes are invalid; limit is capped at 1000 (zero is empty).
/// Only paths readable under this project's root are returned; external
/// content roots are excluded. Cancellation returns cancelled; exceeding the
/// current call's deadline fails. A closed project is not-found.
/// Requires `project.read`. New in 1.3.
symbols: func(project: borrow<project>, query: string, limit: u32) -> result<list<symbol-info>, error>;
/// A project open in the IDE.
resource project {
/// Absolute path of the project root. Requires `project.read`.
+21 -1
View File
@@ -1,7 +1,7 @@
package openidea:[email protected];
interface workspace {
use types.{error};
use types.{error, location};
/// Requests File > Open in this command/event's window (or a new window),
/// including the usual project trust prompt. Needs no capability.
@@ -30,6 +30,26 @@ interface workspace {
modified: option<u64>,
}
/// A symbol in the host's current in-memory project index (new in 1.3).
record symbol-info {
name: string,
/// Host symbol kind, in camelCase (`class`, `method`, `enumMember`, …).
kind: string,
container: option<string>,
location: location,
}
/// Fuzzy symbol search (CamelHump and container-qualified queries) over the
/// current index only; never starts or waits for a language server. Results
/// may be incomplete while indexing and ranges reflect the indexed text,
/// not necessarily unsaved edits. Empty queries return no symbols. Queries
/// over 256 UTF-8 bytes are invalid; limit is capped at 1000 (zero is empty).
/// Only paths readable under this project's root are returned; external
/// content roots are excluded. Cancellation returns cancelled; exceeding the
/// current call's deadline fails. A closed project is not-found.
/// Requires `project.read`. New in 1.3.
symbols: func(project: borrow<project>, query: string, limit: u32) -> result<list<symbol-info>, error>;
/// A project open in the IDE.
resource project {
/// Absolute path of the project root. Requires `project.read`.