feat(plugins): use indexed anchors for usages and rename (#2050)

Closes #1943

Find Usages and Rename use document-indexer definitions and references next to language-server results. An indexed rename previews every name anchor and applies through the existing atomic workspace edit with grouped undo, guarded by index synchronization, canonical paths and document snapshots; a superseded rename preparation can't revive an obsolete session. Language-server Find Usages and Rename are unchanged. Released WIT is unchanged.

Co-authored-by: arnaud-ritti <[email protected]>
Цей коміт міститься в:
Arnaud Ritti authored and GitHub committed 2026-11-08 07:40:08 +08:00
1 джерело 63065bab64
коміт 2329f1e955
30 files changed
+1582 -62

No files matched your search

+455 -27
Переглянути файл
@@ -396,25 +396,144 @@ pub async fn lsp_references(
libraries: bool,
on_partial: super::Channel<References>,
) -> IdeResult<References> {
let lsp = host.project(window)?.lsp.clone();
let project = host.project(window)?;
let lsp = project.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp
.references_streaming(
doc,
pos,
include_declaration,
project_wide,
libraries,
cancel,
Some(&|refs| {
let _ = on_partial.send(refs);
}),
)
.await;
let result = async {
let symbol = indexed_symbol(host, window, doc, pos).await?;
if let Some(symbol) = &symbol {
let project = project.clone();
let snapshot = symbol.clone();
let cancellation = cancel.clone();
blocking(move || validate_indexed_symbol(&project, &snapshot, Some(&cancellation))).await?;
let _ = on_partial.send(merge_indexed_references(References::default(), symbol));
}
let result = lsp
.references_streaming(
doc,
pos,
include_declaration,
project_wide,
libraries,
cancel.clone(),
Some(&|refs| {
let refs =
symbol.as_ref().map_or_else(|| refs.clone(), |s| merge_indexed_references(refs.clone(), s));
let _ = on_partial.send(refs);
}),
)
.await;
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
match symbol {
None => result,
Some(symbol) => {
let server = match result {
Ok(server) => server,
Err(e) => References { incomplete: indexed_lsp_error(e)?, ..Default::default() },
};
blocking(move || {
validate_indexed_symbol(&project, &symbol, Some(&cancel))?;
Ok(merge_indexed_references(server, &symbol))
})
.await
}
}
}
.await;
host.end_request(request);
result
}
/// Native servers can decline names owned by indexers. A failed native query
/// keeps the graph usable but must not look like a complete merged result.
fn indexed_lsp_error(error: ide_core::IdeError) -> IdeResult<Option<ReferencesIncomplete>> {
match error.kind {
ErrorKind::NotFound | ErrorKind::Unavailable => Ok(None),
ErrorKind::Protocol => Ok(Some(ReferencesIncomplete::ServerFailed)),
_ => Err(error),
}
}
async fn indexed_symbol(
host: &Host,
window: &str,
doc: DocId,
pos: Pos,
) -> IdeResult<Option<crate::plugins::navigation::IndexedSymbol>> {
let Some(plugins) = crate::plugins::editor_plugin_host() else { return Ok(None) };
let project = host.project(window)?;
let client = window.to_owned();
blocking(move || {
let Ok((path, _)) = project.documents.identity(doc) else { return Ok(None) };
let path = ide_plugin::resolve_real(&path);
Ok(plugins.indexed_symbol_at(&project.root, &path, pos, Some(&client)))
})
.await
}
fn validate_indexed_symbol(
project: &crate::Project,
symbol: &crate::plugins::navigation::IndexedSymbol,
cancel: Option<&ide_core::CancellationToken>,
) -> IdeResult<()> {
for file in &symbol.files {
if cancel.is_some_and(|cancel| cancel.is_cancelled()) {
return Err(ide_core::IdeError::cancelled());
}
let stale = || {
ide_core::IdeError::new(ErrorKind::OutOfSync, "indexed document changed; wait for indexing and try again")
};
if ide_plugin::resolve_in_root(&project.root, &file.path.to_string_lossy()).ok().as_ref() != Some(&file.path) {
return Err(stale());
}
let text = if let Some(doc) = project.documents.by_path(&file.path) {
let snapshot = project.documents.text_snapshot(doc)?;
if snapshot.large || file.version.is_some_and(|v| v != snapshot.version) {
return Err(stale());
}
snapshot.text
} else {
let loaded = project
.documents
.load_file_bounded(&file.path, crate::plugins::navigation::MAX_DOCUMENT_BYTES)
.map_err(|_| stale())?;
if loaded.binary || loaded.large {
return Err(stale());
}
loaded.text
};
if text.len() > crate::plugins::navigation::MAX_DOCUMENT_BYTES
|| crate::plugins::navigation::snapshot_hash(&text) != file.hash
{
return Err(stale());
}
let text = ide_text::Document::new(&text);
for range in &file.ranges {
if [range.start, range.end].into_iter().any(|pos| text.offset_to_pos(text.pos_to_offset(pos)) != pos) {
return Err(ide_core::IdeError::invalid("indexed name is not on a UTF-16 character boundary"));
}
}
}
Ok(())
}
fn merge_indexed_references(mut server: References, symbol: &crate::plugins::navigation::IndexedSymbol) -> References {
let key =
|location: &Location| (dunce::simplified(&location.path).to_path_buf(), location.range, location.uri.clone());
let mut seen: std::collections::HashSet<_> = server.locations.iter().map(key).collect();
for file in &symbol.files {
for range in &file.ranges {
let location = Location::file(file.path.clone(), *range);
if seen.insert(key(&location)) {
server.locations.push(location);
}
}
}
server
}
pub async fn lsp_document_highlights(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Vec<Highlight>> {
host.project(window)?.lsp.document_highlights(doc, pos).await
}
@@ -667,6 +786,14 @@ async fn is_client_follow_up(
}
pub async fn lsp_prepare_rename(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<Range>> {
if let Some(symbol) = indexed_symbol(host, window, doc, pos).await? {
let project = host.project(window)?;
return blocking(move || {
validate_indexed_symbol(&project, &symbol, None)?;
Ok(Some(symbol.range))
})
.await;
}
host.project(window)?.lsp.prepare_rename(doc, pos).await
}
@@ -733,20 +860,133 @@ pub async fn lsp_rename(
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let cancel = host.begin_request(request);
let result = project.lsp.rename(doc, pos, new_name, cancel).await;
host.end_request(request);
let mut renamed = result?;
blocking(move || {
if project.documents.version(doc)? != version {
return Err(ide_core::IdeError::new(
ide_core::error::ErrorKind::OutOfSync,
"document changed while computing rename; request it again",
));
let result = async {
let symbol = indexed_symbol(host, window, doc, pos).await?;
let mut native_owned = false;
let result = async {
if let Some(symbol) = &symbol {
let range = tokio::select! {
_ = cancel.cancelled() => return Err(ide_core::IdeError::cancelled()),
range = project.lsp.prepare_rename(doc, pos) => range?,
};
if range != Some(symbol.range) {
return Ok(Renamed::default());
}
native_owned = true;
}
project.lsp.rename(doc, pos, new_name.clone(), cancel.clone()).await
}
project.documents.prepare_workspace_edit(&mut renamed.edit)?;
Ok(renamed)
})
.await
.await;
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
let mut renamed = match result {
Ok(renamed) => renamed,
Err(e) if symbol.is_some() && !native_owned => {
Renamed { incomplete: indexed_lsp_error(e)?, ..Default::default() }
}
Err(e) => return Err(e),
};
if native_owned
&& !renamed.edit.files.iter().any(|file| match file {
ide_core::FileEdit::Edit { edits, .. } => !edits.is_empty(),
_ => true,
})
{
return Err(ide_core::IdeError::invalid("Native server declined rename"));
}
blocking(move || {
if project.documents.version(doc)? != version {
return Err(ide_core::IdeError::new(
ErrorKind::OutOfSync,
"document changed while computing rename; request it again",
));
}
if let Some(symbol) = &symbol {
merge_indexed_rename(&mut renamed, symbol, &new_name, &project.documents);
}
project.documents.prepare_workspace_edit(&mut renamed.edit)?;
if let Some(symbol) = &symbol {
validate_indexed_symbol(&project, symbol, Some(&cancel))?;
}
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
Ok(renamed)
})
.await
}
.await;
host.end_request(request);
result
}
fn merge_indexed_rename(
renamed: &mut Renamed,
symbol: &crate::plugins::navigation::IndexedSymbol,
new_name: &str,
documents: &ide_workspace::Documents,
) {
use ide_core::FileEdit;
renamed.preview = true;
let server = std::mem::take(&mut renamed.edit.files);
let mut additions: Vec<Vec<FileEdit>> = vec![vec![]; server.len() + 1];
for file in &symbol.files {
let mut path = dunce::simplified(&file.path).to_path_buf();
let mut at = server.len();
for (index, operation) in server.iter().enumerate() {
match operation {
FileEdit::Edit { path: target, .. } if dunce::simplified(target) == path.as_path() => {
at = index;
break;
}
FileEdit::Rename { from, to } => {
if let Ok(suffix) = path.strip_prefix(dunce::simplified(from)) {
path = dunce::simplified(to).join(suffix);
}
}
_ => {}
}
}
let covered: Vec<_> = server[at..]
.iter()
.take_while(|operation| matches!(operation, FileEdit::Edit { .. }))
.filter_map(|operation| match operation {
FileEdit::Edit { path: target, edits, .. } if dunce::simplified(target) == path.as_path() => {
Some(edits)
}
_ => None,
})
.flatten()
.collect();
let edits: Vec<_> = file
.ranges
.iter()
.filter(|range| {
!covered.iter().any(|native| {
native.range == **range || (native.range.start < range.end && range.start < native.range.end)
})
})
.map(|range| TextEdit { range: *range, new_text: new_name.to_owned() })
.collect();
if edits.is_empty() {
continue;
}
additions[at].push(FileEdit::Edit {
path: path.into(),
version: documents
.by_path(&file.path)
.and_then(|id| documents.version(id).ok())
.and_then(|v| i32::try_from(v).ok()),
edits,
});
}
for (index, operation) in server.into_iter().enumerate() {
renamed.edit.files.push(operation);
renamed.edit.files.append(&mut additions[index]);
}
renamed.edit.files.append(additions.last_mut().expect("one trailing batch"));
renamed.edit.deduplicate();
}
pub async fn lsp_format(
@@ -1103,6 +1343,194 @@ pub(crate) fn register(d: &mut Dispatcher) {
mod tests {
use super::*;
#[test]
fn indexed_names_survive_native_refusal_without_hiding_cancellation_or_staleness() {
use ide_core::IdeError;
for kind in [ErrorKind::NotFound, ErrorKind::Unavailable] {
assert_eq!(indexed_lsp_error(IdeError::new(kind, "no native provider")).unwrap(), None);
}
assert_eq!(
indexed_lsp_error(IdeError::new(ErrorKind::Protocol, "not a native identifier")).unwrap(),
Some(ReferencesIncomplete::ServerFailed)
);
for kind in [ErrorKind::Cancelled, ErrorKind::OutOfSync, ErrorKind::InvalidInput] {
assert_eq!(indexed_lsp_error(IdeError::new(kind, "must propagate")).unwrap_err().kind, kind);
}
}
#[test]
fn indexed_results_merge_with_lsp_locations_edits_and_incompleteness() {
use crate::plugins::navigation::{IndexedAnchors, IndexedSymbol};
use ide_core::{FileEdit, LanguageId};
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let shared = root.join("shared.idx");
let native = root.join("native.txt");
std::fs::write(&shared, "old old").unwrap();
std::fs::write(&native, "old").unwrap();
let range = Range::new(Pos::new(0, 0), Pos::new(0, 3));
let other = Range::new(Pos::new(0, 4), Pos::new(0, 7));
let symbol = IndexedSymbol {
range,
files: vec![IndexedAnchors {
path: shared.clone(),
version: None,
hash: blake3::hash(b"old old"),
ranges: vec![range, other],
}],
};
// Native file URIs have ordinary Windows paths; index snapshots retain
// canonical verbatim paths. Both identify the same file and edit batch.
let server_shared = dunce::simplified(&shared).to_path_buf();
let server_native = dunce::simplified(&native).to_path_buf();
#[cfg(windows)]
assert_ne!(shared, server_shared);
let location = Location::file(server_shared.clone(), range);
let native_location = Location::file(server_native.clone(), range);
let merged = merge_indexed_references(
References {
locations: vec![location.clone(), native_location.clone()],
incomplete: Some(ReferencesIncomplete::Indexing),
libraries_skipped: true,
..Default::default()
},
&symbol,
);
assert_eq!(merged.locations, vec![location, native_location, Location::file(shared.clone(), other)]);
assert_eq!(merged.incomplete, Some(ReferencesIncomplete::Indexing));
assert!(merged.libraries_skipped);
let edit = |path, range| FileEdit::Edit {
path,
version: None,
edits: vec![TextEdit { range, new_text: "new".into() }],
};
let mut renamed = Renamed {
edit: WorkspaceEdit {
files: vec![edit(server_shared.clone().into(), range), edit(server_native.into(), range)],
..Default::default()
},
incomplete: Some(ReferencesIncomplete::FilesSkipped),
..Default::default()
};
let docs = ide_workspace::Documents::new(
std::sync::Arc::new(ide_core::NullSink),
Box::new(|_: &Path, _: Option<&str>| LanguageId::plain_text()),
);
merge_indexed_rename(&mut renamed, &symbol, "new", &docs);
assert!(renamed.preview);
assert_eq!(renamed.incomplete, Some(ReferencesIncomplete::FilesSkipped));
assert_eq!(renamed.edit.files.len(), 2);
docs.prepare_workspace_edit(&mut renamed.edit).unwrap();
docs.apply_workspace_edit(&renamed.edit).unwrap();
assert_eq!(std::fs::read_to_string(&shared).unwrap(), "new new");
assert_eq!(std::fs::read_to_string(&native).unwrap(), "new");
// Native rewrites preserve syntax (for example a shorthand property key).
std::fs::write(&shared, "old old").unwrap();
let mut rewritten = Renamed {
edit: WorkspaceEdit {
files: vec![FileEdit::Edit {
path: shared.clone().into(),
version: None,
edits: vec![TextEdit { range, new_text: "old: new".into() }],
}],
..Default::default()
},
..Default::default()
};
merge_indexed_rename(&mut rewritten, &symbol, "new", &docs);
docs.prepare_workspace_edit(&mut rewritten.edit).unwrap();
docs.apply_workspace_edit(&rewritten.edit).unwrap();
assert_eq!(std::fs::read_to_string(&shared).unwrap(), "old: new new");
std::fs::write(&shared, "new new").unwrap();
// A class rename may move its file before editing the new path. Indexed
// anchors still describe the original file and must join that text batch.
let moved = root.join("moved.idx");
let server_moved = dunce::simplified(&moved).to_path_buf();
let mut renamed = Renamed {
edit: WorkspaceEdit {
files: vec![
FileEdit::Rename { from: server_shared.into(), to: server_moved.clone().into() },
FileEdit::Edit {
path: server_moved.into(),
version: None,
edits: vec![TextEdit { range, new_text: "moved".into() }],
},
],
..Default::default()
},
..Default::default()
};
merge_indexed_rename(&mut renamed, &symbol, "moved", &docs);
docs.prepare_workspace_edit(&mut renamed.edit).unwrap();
docs.apply_workspace_edit(&renamed.edit).unwrap();
assert!(!shared.exists());
assert_eq!(std::fs::read_to_string(&moved).unwrap(), "moved moved");
}
#[test]
fn indexed_edits_yield_to_native_intersections_but_keep_boundary_insertions() {
use crate::plugins::navigation::{IndexedAnchors, IndexedSymbol};
use ide_core::{FileEdit, LanguageId};
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("shared.idx");
std::fs::write(&path, "old old").unwrap();
let range = Range::new(Pos::new(0, 0), Pos::new(0, 3));
let symbol = IndexedSymbol {
range,
files: vec![IndexedAnchors {
path: path.clone(),
version: None,
hash: blake3::hash(b"old old"),
ranges: vec![range],
}],
};
let docs = ide_workspace::Documents::new(
std::sync::Arc::new(ide_core::NullSink),
Box::new(|_: &Path, _: Option<&str>| LanguageId::plain_text()),
);
for (start, end, expected) in [
(0, 3, "native old"),
(0, 4, "nativeold"),
(1, 4, "onativeold"),
(1, 2, "onatived old"),
(1, 1, "onativeld old"),
(0, 0, "nativeindexed old"),
(3, 3, "indexednative old"),
(3, 4, "indexednativeold"),
(4, 7, "indexed native"),
] {
let native =
TextEdit { range: Range::new(Pos::new(0, start), Pos::new(0, end)), new_text: "native".into() };
let mut renamed = Renamed {
edit: WorkspaceEdit {
files: vec![FileEdit::Edit {
path: path.clone().into(),
version: None,
edits: vec![native.clone()],
}],
..Default::default()
},
..Default::default()
};
merge_indexed_rename(&mut renamed, &symbol, "indexed", &docs);
let edits: Vec<_> = renamed
.edit
.files
.iter()
.flat_map(|file| match file {
FileEdit::Edit { edits, .. } => edits.clone(),
_ => vec![],
})
.collect();
assert!(edits.contains(&native));
let mut text = ide_text::Document::new("old old");
text.apply_edits(&edits).unwrap();
assert_eq!(text.text(), expected, "native range {start}..{end}");
}
}
#[test]
fn a_plugin_action_with_data_and_no_edits_is_resolved_by_its_plugin() {
let action = |edits: Vec<(std::path::PathBuf, Vec<TextEdit>)>, data: Option<&str>| ide_plugin::CodeAction {
+4 -13
Переглянути файл
@@ -83,23 +83,14 @@ pub async fn plugin_navigation_index(
}
(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 {
let Ok(loaded) = project.documents.load_file_bounded(&path, super::navigation::MAX_DOCUMENT_BYTES)
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()
{
if loaded.binary || loaded.large {
continue;
}
let text = loaded.text;
let language = project.languages.detect(&path, text.lines().next());
(text, None, language)
};
+89 -1
Переглянути файл
@@ -28,6 +28,7 @@ pub use ide_plugin::indexing::*;
struct File {
version: Option<u32>,
scope: String,
hash: blake3::Hash,
edges: Edges,
}
#[derive(Default)]
@@ -61,6 +62,11 @@ impl Graph {
}
targets
}
fn scoped_targets(&self, key: &str, scope: &str) -> Vec<&Location> {
let targets = self.targets(key);
let local = targets.iter().any(|t| self.files.get(&t.path.0).is_some_and(|f| f.scope == scope));
targets.into_iter().filter(|t| !local || self.files.get(&t.path.0).is_some_and(|f| f.scope == scope)).collect()
}
fn remove(&mut self, path: &Path) {
if let Some(file) = self.files.remove(path) {
self.edges -= file.edges.definitions.len()
@@ -303,7 +309,7 @@ impl PluginHost {
{
return Err(IdeError::unavailable("navigation association limit reached"));
}
graph.insert(path.to_owned(), File { version, scope: scope.to_owned(), edges });
graph.insert(path.to_owned(), File { version, scope: scope.to_owned(), hash: snapshot_hash(text), edges });
}
Ok(())
}
@@ -365,6 +371,70 @@ impl PluginHost {
}
result.filter(|r| !r.targets.is_empty())
}
/// Definitions and references of each indexed key under the caret. Markers
/// are navigation affordances, not name anchors, and never participate.
pub fn indexed_symbol_at(&self, root: &Path, path: &Path, pos: Pos, client: Option<&str>) -> Option<IndexedSymbol> {
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<IndexedSymbol> = 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(source) = graph.files.get(path) else { continue };
let anchors: Vec<_> = source
.edges
.definitions
.iter()
.chain(&source.edges.references)
.filter(|anchor| contains(anchor.range, pos))
.collect();
let Some(primary) = anchors.first() else { continue };
let keys: std::collections::HashSet<_> = anchors.iter().map(|anchor| anchor.key.as_str()).collect();
let targets: std::collections::HashSet<_> =
keys.iter().flat_map(|key| graph.scoped_targets(key, &source.scope)).collect();
let answer = result.get_or_insert_with(|| IndexedSymbol { range: primary.range, files: vec![] });
for (path, file) in &graph.files {
let mut ranges = std::collections::BTreeSet::new();
for candidate in &file.edges.definitions {
if targets.contains(&Location::file(path.clone(), candidate.range))
|| (targets.is_empty() && file.scope == source.scope && keys.contains(candidate.key.as_str()))
{
ranges.insert(candidate.range);
}
}
let mut matches = HashMap::new();
for candidate in &file.edges.references {
let same = *matches.entry(candidate.key.as_str()).or_insert_with(|| {
let resolved = graph.scoped_targets(&candidate.key, &file.scope);
resolved.iter().any(|target| targets.contains(target))
|| (targets.is_empty()
&& resolved.is_empty()
&& file.scope == source.scope
&& keys.contains(candidate.key.as_str()))
});
if same {
ranges.insert(candidate.range);
}
}
if !ranges.is_empty() {
answer.files.push(IndexedAnchors {
path: path.clone(),
version: file.version,
hash: file.hash,
ranges: ranges.into_iter().collect(),
});
}
}
}
result
}
/// Gutter markers of visible files. Uses only existing graph entries; never executes WASM.
pub fn navigation_markers(
&self,
@@ -423,6 +493,24 @@ impl PluginHost {
}
}
#[derive(Clone)]
pub struct IndexedSymbol {
pub range: Range,
pub files: Vec<IndexedAnchors>,
}
#[derive(Clone)]
pub struct IndexedAnchors {
pub path: PathBuf,
pub version: Option<u32>,
pub hash: blake3::Hash,
pub ranges: Vec<Range>,
}
pub(crate) fn snapshot_hash(text: &str) -> blake3::Hash {
blake3::hash(ide_text::normalize_line_endings(text).as_bytes())
}
#[derive(Clone, Deserialize)]
#[serde(deny_unknown_fields)]
struct Decl {
+54
Переглянути файл
@@ -140,6 +140,21 @@ fn permissions_and_paths_are_checked_before_indexing() {
);
}
#[test]
fn indexed_refactorings_drop_graphs_after_permission_revocation_or_lost_trust() {
let f = Fixture::new();
let path = f.index("source.idx", "def item\nref item\n", &[], Some(1), "app");
let pos = Pos::new(0, 4);
assert!(f.host.indexed_symbol_at(&f.root, &path, pos, None).is_some());
f.host.set_project_policy(Arc::new(|_| false));
assert!(f.host.indexed_symbol_at(&f.root, &path, pos, None).is_none());
f.host.set_project_policy(Arc::new(|_| true));
f.host.set_capability("test.indexer", "project.read", false).unwrap();
assert!(f.host.indexed_symbol_at(&f.root, &path, pos, None).is_none());
f.host.set_capability("test.indexer", "project.read", true).unwrap();
assert!(f.host.indexed_symbol_at(&f.root, &path, pos, None).is_none(), "regrant requires fresh snapshots");
}
#[test]
fn syntax_symbols_aliases_and_utf16_ranges_cross_the_wasm_boundary() {
let f = Fixture::new();
@@ -239,3 +254,42 @@ fn invalid_ranges_and_oversized_documents_never_publish() {
}
assert!(f.host.navigation_markers(&f.root, &[path], None, |_| None).is_empty());
}
#[test]
fn usages_from_definitions_and_references_follow_aliases_module_scope_and_client() {
let f = Fixture::new();
let definition = f.index("a/source.idx", "def item\nsymbols parent#\n", &[method()], Some(1), "a");
f.index("a/child.idx", "alias child# parent#\n", &[], None, "a");
let reference = f.index("a/use.idx", "ref child#item\n", &[], Some(1), "a");
let other = f.index("b/source.idx", "def item\nsymbols parent#\n", &[method()], Some(1), "b");
let external = f.index("c/use.idx", "ref child#item\n", &[], Some(1), "c");
let unrelated = f.index("b/use.idx", "ref child#item\n", &[], Some(1), "b");
for (path, pos) in [(&definition, Pos::new(0, 4)), (&reference, Pos::new(0, 6))] {
let symbol = f.host.indexed_symbol_at(&f.root, path, pos, None).unwrap();
assert!(symbol.files.iter().any(|f| f.path == definition));
assert!(symbol.files.iter().any(|f| f.path == reference));
assert!(!symbol.files.iter().any(|f| f.path == other), "prefer same-module definitions and their uses");
assert!(symbol.files.iter().any(|f| f.path == external), "external reference resolves to this definition");
assert!(!symbol.files.iter().any(|f| f.path == unrelated), "reference resolves to its own module");
assert_eq!(symbol.files.iter().map(|f| f.ranges.len()).sum::<usize>(), 3);
}
assert!(f.host.indexed_symbol_at(&f.root, &definition, Pos::new(0, 0), None).is_none());
assert!(f.host.indexed_symbol_at(&f.root, &definition, Pos::new(0, 4), Some("other-window")).is_none());
f.host.set_project_policy(Arc::new(|_| false));
assert!(f.host.indexed_symbol_at(&f.root, &definition, Pos::new(0, 4), None).is_none());
}
#[test]
fn cross_module_reference_and_definition_have_the_same_rename_anchors() {
let f = Fixture::new();
let definition = f.index("a/source.idx", "def item\nsymbols parent#\n", &[method()], Some(1), "a");
f.index("a/alias.idx", "alias child# parent#\n", &[], None, "a");
let external = f.index("b/use.idx", "ref item\n", &[], Some(1), "b");
let alias = f.index("c/use.idx", "ref child#item\n", &[], Some(1), "c");
for path in [&definition, &external, &alias] {
let symbol = f.host.indexed_symbol_at(&f.root, path, Pos::new(0, 4), None).unwrap();
let paths: Vec<_> = symbol.files.iter().map(|f| f.path.clone()).collect();
assert_eq!(paths, vec![definition.clone(), external.clone(), alias.clone()]);
assert_eq!(symbol.files.iter().map(|f| f.ranges.len()).sum::<usize>(), 3);
}
}
+394
Переглянути файл
@@ -0,0 +1,394 @@
//! Indexed names use the same preview/apply/undo commands as language-server refactorings.
use ide_core::{IdeEvent, Pos, RequestId, error::ErrorKind};
use ide_host::{
Host, HostConfig, ops,
plugins::{Consent, PluginHost, navigation::Snapshot, set_editor_providers},
};
use ide_plugin::{Invocation, Limits};
use parking_lot::Mutex;
use std::{path::Path, sync::Arc};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn indexed_usages_rename_preview_staleness_and_grouped_undo() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let first = root.join("definition.idx");
let second = root.join("usage.idx");
let source = "def 😀route\nref 😀route\n";
let usage = "ref 😀route\nref other\n";
std::fs::write(&first, source).unwrap();
std::fs::write(&second, usage).unwrap();
let events = Arc::new(Mutex::new(vec![]));
let captured = events.clone();
let host = Host::new(HostConfig {
config_dir: root.join("config"),
data_dir: root.join("data"),
local_data_dir: root.join("local"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |_| {
let events = captured.clone();
Arc::new(move |e| events.lock().push(e))
}),
owner: |window| window,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let project = host.open_light("main", &first).unwrap();
// Editors retain ordinary Windows paths; index snapshots use canonical
// (verbatim on Windows) paths and the host's own project-root identity.
let doc = project.documents.open(dunce::simplified(&first)).unwrap();
let plugins =
Arc::new(PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap());
plugins.set_project_policy(Arc::new(|_| true));
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/indexer");
plugins.install_from_dir(&fixture, &Consent::all()).unwrap();
// Two independently published graphs may contain exactly the same anchors.
let duplicate = root.join("duplicate");
std::fs::create_dir_all(duplicate.join("dist")).unwrap();
std::fs::copy(fixture.join("dist/plugin.wasm"), duplicate.join("dist/plugin.wasm")).unwrap();
std::fs::write(
duplicate.join("plugin.toml"),
std::fs::read_to_string(fixture.join("plugin.toml")).unwrap().replace("test.indexer", "test.duplicate"),
)
.unwrap();
plugins.install_from_dir(&duplicate, &Consent::all()).unwrap();
set_editor_providers(Some(&plugins));
struct Clear;
impl Drop for Clear {
fn drop(&mut self) {
set_editor_providers(None);
}
}
let _clear = Clear;
// LSP-managed library documents do not belong to Documents. Optional
// index lookup must not replace their native result with a missing-doc error.
let virtual_doc = ide_core::DocId(1_000_000);
project.lsp.did_open_virtual(
virtual_doc,
"jdt://contents/test/Route.class",
&ide_core::LanguageId::new("java"),
0,
"class Route {}",
);
let virtual_result = ops::lsp::lsp_references(
&host,
"main",
RequestId(99),
virtual_doc,
Pos::new(0, 6),
false,
false,
false,
ops::Channel::new(|_| {}),
)
.await
.unwrap_err();
assert_eq!(virtual_result.kind, ErrorKind::Unavailable, "retain the LSP's native result for virtual documents");
project.lsp.did_close(virtual_doc);
let index = || {
for (path, text, version) in [(&first, source, Some(doc.version)), (&second, usage, None)] {
plugins
.index_navigation(
&project.root,
Snapshot { path, text, version, symbols: &[], scope: "app" },
Invocation {
client: Some("main".into()),
workspace_root: Some(project.root.clone()),
..Default::default()
},
)
.unwrap();
}
};
index();
for pos in [Pos::new(0, 4), Pos::new(1, 5)] {
let prepared = ops::lsp::lsp_prepare_rename(&host, "main", doc.id, pos).await.unwrap().unwrap();
assert_eq!((prepared.start.col, prepared.end.col), (4, 11));
let partial = Arc::new(Mutex::new(vec![]));
let captured = partial.clone();
let refs = ops::lsp::lsp_references(
&host,
"main",
RequestId(1),
doc.id,
pos,
false,
true,
false,
ops::Channel::new(move |r| captured.lock().push(r)),
)
.await
.unwrap();
assert_eq!(refs.locations.len(), 3, "definitions and references, deduplicated across plugins");
assert_eq!(partial.lock()[0].locations.len(), 3);
}
assert!(
ops::lsp::lsp_prepare_rename(&host, "main", doc.id, Pos::new(0, 0)).await.is_err(),
"unowned text falls through to the unavailable LSP"
);
let renamed =
ops::lsp::lsp_rename(&host, "main", RequestId(2), doc.id, Pos::new(0, 4), "new.route".into()).await.unwrap();
assert!(renamed.preview);
assert_eq!(renamed.edit.files.len(), 2);
assert_eq!(renamed.edit.preconditions.len(), 2);
assert_eq!(std::fs::read_to_string(&first).unwrap(), source, "creating/cancelling preview changes nothing");
assert_eq!(std::fs::read_to_string(&second).unwrap(), usage);
let opened_usage = project.documents.open(&second).unwrap();
let changed_version = project
.documents
.edit(
opened_usage.id,
opened_usage.version,
ide_core::ChangeSet { changes: vec![ide_core::TextChange { from: 4, to: 11, insert: "changed".into() }] },
)
.unwrap();
assert_eq!(
ops::lsp::lsp_rename(&host, "main", RequestId(20), doc.id, Pos::new(0, 4), "new.route".into())
.await
.unwrap_err()
.kind,
ErrorKind::OutOfSync,
"a closed-file snapshot cannot hide newer unsaved contents"
);
project
.documents
.edit(
opened_usage.id,
changed_version,
ide_core::ChangeSet { changes: vec![ide_core::TextChange { from: 4, to: 11, insert: "😀route".into() }] },
)
.unwrap();
project.documents.close(opened_usage.id);
// A closed file changing during preview rejects the whole operation.
std::fs::write(&second, "ref changed\n").unwrap();
assert_eq!(
ops::lsp::apply_workspace_edit(&host, "main", renamed.edit.clone()).await.unwrap_err().kind,
ErrorKind::OutOfSync
);
assert_eq!(std::fs::read_to_string(&first).unwrap(), source);
assert_eq!(
ops::lsp::lsp_rename(&host, "main", RequestId(3), doc.id, Pos::new(0, 4), "new.route".into())
.await
.unwrap_err()
.kind,
ErrorKind::OutOfSync,
"stale closed-file anchors cannot be renamed"
);
std::fs::write(&second, usage).unwrap();
let renamed =
ops::lsp::lsp_rename(&host, "main", RequestId(4), doc.id, Pos::new(1, 4), "new.route".into()).await.unwrap();
ops::lsp::apply_workspace_edit(&host, "main", renamed.edit).await.unwrap();
assert_eq!(std::fs::read_to_string(&first).unwrap(), "def new.route\nref new.route\n");
assert_eq!(std::fs::read_to_string(&second).unwrap(), "ref new.route\nref other\n");
let id = events
.lock()
.iter()
.find_map(|e| match e {
IdeEvent::WorkspaceEditApplied { id, .. } => Some(*id),
_ => None,
})
.unwrap();
ops::lsp::undo_workspace_edit(&host, "main", id).await.unwrap();
assert_eq!(std::fs::read_to_string(&first).unwrap(), source);
assert_eq!(std::fs::read_to_string(&second).unwrap(), usage);
ops::lsp::redo_workspace_edit(&host, "main", id).await.unwrap();
assert_eq!(std::fs::read_to_string(&second).unwrap(), "ref new.route\nref other\n");
// A matching native preparation owns the decision, including a null rename.
std::fs::write(&first, source).unwrap();
std::fs::write(&second, usage).unwrap();
project.documents.close(doc.id);
let doc = project.documents.open(&first).unwrap();
for (path, text, version) in [(&first, source, Some(doc.version)), (&second, usage, None)] {
plugins
.index_navigation(
&project.root,
Snapshot { path, text, version, symbols: &[], scope: "app" },
Invocation {
client: Some("main".into()),
workspace_root: Some(project.root.clone()),
..Default::default()
},
)
.unwrap();
}
native_rename_refusal(&host, &project, doc.id, &first, source).await;
plugins.set_enabled("test.indexer", false).unwrap();
plugins.set_enabled("test.duplicate", false).unwrap();
assert!(plugins.indexed_symbol_at(&project.root, &first, Pos::new(0, 4), Some("main")).is_none());
plugins.set_enabled("test.indexer", true).unwrap();
project.documents.close(doc.id);
let invalid = "def 😀\nsymbols \n";
std::fs::write(&first, invalid).unwrap();
let doc = project.documents.open(&first).unwrap();
let half_surrogate = ide_language::SymbolTag {
name: "broken".into(),
kind: ide_core::SymbolKind::Method,
container: None,
range: ide_core::Range::new(Pos::new(0, 4), Pos::new(0, 5)),
};
plugins
.index_navigation(
&project.root,
Snapshot {
path: &first,
version: Some(doc.version),
text: invalid,
symbols: &[half_surrogate],
scope: "app",
},
Invocation {
client: Some("main".into()),
workspace_root: Some(project.root.clone()),
..Default::default()
},
)
.unwrap();
assert_eq!(
ops::lsp::lsp_prepare_rename(&host, "main", doc.id, Pos::new(0, 4)).await.unwrap_err().kind,
ErrorKind::InvalidInput,
"malformed UTF-16 anchors cannot split a character"
);
project.documents.close(doc.id);
host.set_project_trust(&root, true, false).await.unwrap();
let project = host.open_project("main", &root).unwrap();
// BOM-less UTF-16 detection requires mostly ASCII pairs, even with emoji anchors.
let padding = format!("ignored {}\n", "x".repeat(128));
let encoded_source = format!("{source}{padding}");
let encoded_usage = format!("{usage}{padding}");
for (encoding, bom) in
[("utf-8", true), ("utf-16le", true), ("utf-16be", true), ("utf-16le", false), ("utf-16be", false)]
{
ide_vfs::save_text(&first, &encoded_source, encoding, bom).unwrap();
ide_vfs::save_text(&second, &encoded_usage, encoding, bom).unwrap();
let original_second = std::fs::read(&second).unwrap();
let doc = project.documents.open(&first).unwrap();
assert_eq!(doc.text, encoded_source, "decoded {encoding} BOM={bom}");
// Index open, close, then validate that decoded text retains the same hash.
let usage_doc = project.documents.open(&second).unwrap();
ide_host::plugins::index_ops::plugin_navigation_index(
&host,
plugins.clone(),
"main",
vec![first.clone(), second.clone()],
RequestId(100),
)
.await
.unwrap();
project.documents.close(usage_doc.id);
assert!(
plugins.indexed_symbol_at(&project.root, &first, Pos::new(0, 5), Some("main")).is_some(),
"{encoding} BOM={bom} must publish the open definition"
);
let refs = ops::lsp::lsp_references(
&host,
"main",
RequestId(101),
doc.id,
Pos::new(0, 5),
false,
false,
false,
ops::Channel::new(|_| {}),
)
.await
.unwrap();
assert_eq!(refs.locations.len(), 3, "open-then-closed {encoding} BOM={bom}");
plugins.invalidate_client_navigation(&project.root, Some("main"), Some(std::slice::from_ref(&second)));
ide_host::plugins::index_ops::plugin_navigation_index(
&host,
plugins.clone(),
"main",
vec![second.clone()],
RequestId(102),
)
.await
.unwrap();
let renamed =
ops::lsp::lsp_rename(&host, "main", RequestId(103), doc.id, Pos::new(0, 5), "new😀".into()).await.unwrap();
assert_eq!(renamed.edit.preconditions.len(), 2);
assert_eq!(std::fs::read(&second).unwrap(), original_second, "preview preserves bytes");
ops::lsp::apply_workspace_edit(&host, "main", renamed.edit).await.unwrap();
assert_eq!(
std::fs::read(&second).unwrap(),
ide_vfs::encode(&format!("ref new😀\nref other\n{padding}"), encoding, bom).unwrap(),
"atomic rename preserves {encoding} BOM={bom}"
);
assert_eq!(project.documents.load_file(&first).unwrap().text, format!("def new😀\nref new😀\n{padding}"));
project.documents.close(doc.id);
}
}
async fn native_rename_refusal(
host: &Host,
project: &ide_host::Project,
doc: ide_core::DocId,
path: &Path,
text: &str,
) {
use ide_jsonrpc::{Handler, Peer};
use serde_json::{Value, json};
use std::sync::atomic::{AtomicU8, Ordering};
struct Native(Arc<AtomicU8>);
impl Handler for Native {
fn on_notification(&self, _: &str, _: Value) {}
fn on_request(&self, method: &str, _: Value) -> Result<Value, (i64, String)> {
let mode = self.0.load(Ordering::SeqCst);
Ok(match method {
"initialize" => {
json!({"capabilities": {"textDocumentSync": 1, "renameProvider": {"prepareProvider": true}}})
}
"textDocument/prepareRename" if mode < 2 => {
json!({"start": {"line": 0, "character": 4}, "end": {"line": 0, "character": 11}})
}
"textDocument/prepareRename" if mode == 3 => {
json!({"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 3}})
}
"textDocument/rename" if mode == 1 => json!({"changes": {}}),
_ => Value::Null,
})
}
}
let mode = Arc::new(AtomicU8::new(0));
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let port = listener.local_addr().unwrap().port();
let peers = Arc::new(Mutex::new(Vec::<Peer>::new()));
let accepted = peers.clone();
let state = mode.clone();
let task = tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
let (reader, writer) = stream.into_split();
accepted.lock().push(Peer::connect(reader, writer, Arc::new(Native(state.clone()))));
}
});
let config: ide_language::LanguageServerConfig = toml::from_str(&format!(
"name = 'rename-refusal'\ncommand = 'no-process-required'\nconnect = '127.0.0.1:{port}'"
))
.unwrap();
project.lsp.set_servers_hook(Box::new(move |_, _| vec![config.clone()]));
project.lsp.did_open(doc, path, &ide_core::LanguageId::plain_text(), 0, text);
for value in 0..4 {
mode.store(value, Ordering::SeqCst);
let renamed =
ops::lsp::lsp_rename(host, "main", RequestId(110 + value as u32), doc, Pos::new(0, 5), "new".into()).await;
if value < 2 {
assert_eq!(
renamed.unwrap_err().kind,
ErrorKind::InvalidInput,
"matching owner declines via null/empty edit"
);
} else {
let renamed = renamed.unwrap();
assert!(renamed.preview);
assert_eq!(renamed.edit.files.len(), 2, "unowned native range keeps indexed fallback");
}
}
project.lsp.did_close(doc);
project.lsp.shutdown_all().await;
project.lsp.set_servers_hook(Box::new(|_, _| vec![]));
task.abort();
let _ = task.await;
for peer in peers.lock().drain(..) {
peer.kill();
}
}
+3 -3
Переглянути файл
@@ -2059,7 +2059,7 @@ impl LspManager {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
Ok(Renamed { edit: out.finish(WorkspaceEdit::default())?, incomplete })
Ok(Renamed { preview: false, edit: out.finish(WorkspaceEdit::default())?, incomplete })
}
/// The servers that can rename at `pos`, the first one when none can (a
@@ -2096,7 +2096,7 @@ impl LspManager {
let edit = query().await?;
let empty = edit.files.is_empty();
let w = scope::Widened::only((), empty, &t.server);
return Ok(Renamed { edit, incomplete: w.incomplete });
return Ok(Renamed { preview: false, edit, incomplete: w.incomplete });
}
let files = |e: &WorkspaceEdit| {
e.files
@@ -2122,7 +2122,7 @@ impl LspManager {
let files =
more.into_iter().map(|(path, edits)| ide_core::FileEdit::Edit { path: path.into(), edits, version: None });
convert::merge_workspace_edit(&mut edit, WorkspaceEdit { files: files.collect(), ..Default::default() });
Ok(Renamed { edit, incomplete: w.incomplete })
Ok(Renamed { preview: false, edit, incomplete: w.incomplete })
}
// ---- file operations (`workspace/willRenameFiles`, `workspace/didRenameFiles`) ----
+6
Переглянути файл
@@ -369,6 +369,8 @@ pub enum ReferencesIncomplete {
TooManyFiles,
/// Files that mention the name were too large or unreadable to check.
FilesSkipped,
/// A native query failed; indexed anchors are still available.
ServerFailed,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
@@ -389,6 +391,10 @@ pub struct References {
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct Renamed {
/// Indexed names always require preview, including a single-file rename.
#[serde(default)]
#[specta(optional)]
pub preview: bool,
pub edit: WorkspaceEdit,
pub incomplete: Option<ReferencesIncomplete>,
}
+24
Переглянути файл
@@ -8,3 +8,27 @@ 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.
The fixture requires `project.read`. Its API 1.2 manifest is retained to check
compatibility; Find Usages and Rename consume the same anchors on API 1.3 hosts.
Build with `plugins/build.sh crates/ide-plugin/tests/fixtures/indexer`, then load
this directory as a development plugin in a trusted scratch project containing:
```text
# definition.idx
def route
ref route
# usage.idx
ref route
```
The filenames and comment lines above describe two separate files. Invoke Find
Usages on either `route` anchor: three locations should appear. Rename `route`
to `new.route`: the preview must contain all three anchors. Cancel leaves both
files unchanged; applying changes both files, and one Undo restores both.
Run `cargo test -p ide-host --test plugin_indexing --test plugin_refactoring`
for the real Wasm graph and preview/apply/undo checks, including UTF-16 names,
aliases, duplicate anchors and stale files.
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
190f1d506b854c3dc0ade563b8cda2488767522d4fd653427513ea9d1d83c989
3463e906eff16c16822848fc02cbf5c8c2282f0ae7849b3a9246b4671eb6b297
+78
Переглянути файл
@@ -227,6 +227,49 @@ pub(crate) fn load_text(path: &Path) -> IdeResult<LoadedText> {
})
}
/// Bounded disk snapshots for background consumers that share editor decoding.
pub(crate) fn load_text_bounded(path: &Path, fallback: Option<&str>, max_bytes: usize) -> IdeResult<LoadedText> {
use std::io::Read;
let regular = |meta: &fs::Metadata| meta.is_file() && meta.len() <= max_bytes as u64;
if !regular(&fs::metadata(path)?) {
return Err(IdeError::invalid("document is too large or is not a regular file"));
}
let file = fs::File::open(path)?;
let meta = file.metadata()?;
if !regular(&meta) {
return Err(IdeError::invalid("document is too large or is not a regular file"));
}
let mut bytes = Vec::new();
file.take(max_bytes.saturating_add(1) as u64).read_to_end(&mut bytes)?;
if bytes.len() > max_bytes {
return Err(IdeError::invalid("document is too large"));
}
let len = bytes.len() as u64;
let (mut text, mut enc, has_bom, binary) = decode(&bytes);
if !binary
&& !has_bom
&& enc != UTF_8
&& enc != UTF_16LE
&& enc != UTF_16BE
&& let Some(fallback) = fallback.and_then(|label| Encoding::for_label(label.trim().as_bytes()))
{
text = fallback.decode_without_bom_handling(&bytes).0.into_owned();
enc = fallback;
}
if text.len() > max_bytes {
return Err(IdeError::invalid("decoded document is too large"));
}
Ok(LoadedText {
text,
encoding: encoding_label(enc),
has_bom,
binary,
large: len > LARGE_FILE_THRESHOLD,
too_large: len > READ_ONLY_THRESHOLD,
mtime_ms: mtime_ms(&meta),
})
}
/// Loads `path` decoded as `encoding` (a WHATWG label) instead of detecting it
/// ("Reload with Encoding"). A matching BOM is stripped and reported.
pub(crate) fn load_text_as(path: &Path, encoding: &str) -> IdeResult<LoadedText> {
@@ -562,6 +605,41 @@ mod tests {
assert_eq!(decode_owned(Vec::new()).0, "");
}
#[test]
fn bounded_snapshots_match_editor_decoding_and_enforce_both_limits() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("text");
for (encoding, bom) in [
("utf-8", true),
("utf-16le", true),
("utf-16be", true),
("utf-16le", false),
("utf-16be", false),
("gbk", false),
] {
let text = if encoding == "gbk" {
"中文测试,这是一段中文文本。"
} else {
"hello world 😀\nline two\n"
};
let text = format!("{text}{}", "padding ".repeat(16));
crate::save_text(&path, &text, encoding, bom).unwrap();
let ordinary = crate::load_text_with_fallback(&path, Some(encoding)).unwrap();
let bounded = load_text_bounded(&path, Some(encoding), 1024).unwrap();
assert!(!bounded.binary, "{encoding} BOM={bom}");
assert_eq!(bounded.text, text, "{encoding} BOM={bom}");
assert_eq!(
(&bounded.text, &bounded.encoding, bounded.has_bom),
(&ordinary.text, &ordinary.encoding, ordinary.has_bom)
);
assert!(load_text_bounded(&path, Some(encoding), 2).is_err());
}
fs::write(&path, [0xe9; 4]).unwrap();
assert!(load_text_bounded(&path, Some("windows-1252"), 4).is_err(), "decoded UTF-8 expansion is bounded");
fs::write(&path, b"\x7fELF\0\0").unwrap();
assert!(load_text_bounded(&path, None, 1024).unwrap().binary);
}
#[test]
fn encode_variants() {
assert_eq!(encode("hi", "utf-8", true).unwrap(), b"\xEF\xBB\xBFhi");
+5
Переглянути файл
@@ -204,6 +204,11 @@ pub fn load_text(path: &Path) -> IdeResult<LoadedText> {
encoding::load_text(path)
}
/// Loads a regular file with editor decoding, bounding both disk and decoded bytes.
pub fn load_text_bounded(path: &Path, fallback: Option<&str>, max_bytes: usize) -> IdeResult<LoadedText> {
encoding::load_text_bounded(path, fallback, max_bytes)
}
/// Like [`load_text`], but text that is neither valid UTF-8 nor marked by a BOM
/// is decoded as `fallback` (the configured project encoding) instead of guessed.
pub fn load_text_with_fallback(path: &Path, fallback: Option<&str>) -> IdeResult<LoadedText> {
+10
Переглянути файл
@@ -255,6 +255,16 @@ impl Documents {
Self::load_with_defaults(path, &self.file_defaults())
}
/// Bounded closed-file snapshots use the same project defaults as open documents.
pub fn load_file_bounded(&self, path: &Path, max_bytes: usize) -> IdeResult<ide_vfs::LoadedText> {
let defaults = self.file_defaults();
let mut loaded = ide_vfs::load_text_bounded(path, defaults.fallback_for(&absolute(path)), max_bytes)?;
if !loaded.binary && defaults.native_to_ascii_for(path) {
loaded.text = ide_vfs::decode_properties_escapes(&loaded.text);
}
Ok(loaded)
}
pub fn load_file_as(&self, path: &Path, encoding: &str) -> IdeResult<ide_vfs::LoadedText> {
Self::load_as(path, encoding, self.native_to_ascii_for(path))
}
+29
Переглянути файл
@@ -42,6 +42,35 @@ 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.
## Find Usages and Rename (API 1.3)
Find Usages on a published definition or reference lists every definition and
reference of its key alongside the language server's results. Rename replaces
every such anchor with the new name, merging language-server edits into the
same workspace edit. Alias-linked keys follow the same definition targets and
module preference as navigation. Markers are excluded: their ranges may cover
an annotation or an entire line.
Anchors must cover **exactly the name**, without quotes, delimiters or surrounding
syntax. Use UTF-16 columns, including two columns for an astral character.
Rename always shows a preview, even for a single file; cancelling leaves files
unchanged, and applying it creates one undoable workspace change. The host
checks snapshots containing returned anchors against current buffers and disk
before returning the edit, and checks preview fingerprints again before applying
it. If a document changed, wait for indexing and request the rename again.
Queries flush queued indexing work; they do not rescan the project. External
changes must reach the filesystem watcher and be indexed before newly added
anchors appear. Files without matching anchors are not refreshed by the query.
A language server that declines ownership of the indexed name does not prevent
its indexed anchors from being renamed. Failed reference or name-preparation
queries show an incomplete-search warning. A server that owns the same name
keeps its rename validation; a rejected native rename changes nothing.
These features consume the existing graph; no references or rename export is
needed. Unindexed names continue through the normal language-server path.
## Minimal fixture
`crates/ide-plugin/tests/fixtures/indexer` uses a tiny line-oriented `.idx`
+37
Переглянути файл
@@ -155,6 +155,43 @@ test("⇧F6 renames in place and sends the server's edit to be applied", async (
expect(JSON.stringify(apply)).toContain('"new_text":"placed"');
});
test("indexed single-file rename previews all anchors and applies only after confirmation", async ({
workbench: page,
}) => {
const editor = await openSample(page);
await page.evaluate(
({ file, prepared, uses }) => {
window.__ipc.handlers.lsp_prepare_rename = () => prepared;
window.__ipc.handlers.lsp_rename = (args) => ({
preview: true,
edit: {
files: [{ kind: "edit", path: file, edits: uses.map((range) => ({ range, new_text: args.newName })) }],
preconditions: [{ path: file, hash: "indexed-snapshot" }],
},
incomplete: null,
});
window.__ipc.handlers.apply_workspace_edit = () => null;
},
{ file: FILE, prepared: range(20, 12, 17), uses: [range(20, 12, 17), range(21, 15, 20)] },
);
for (const confirm of [false, true]) {
await caretAt(editor, page, "val order: Order", "order");
await page.keyboard.press("Shift+F6");
await expect(page.getByText(/Press ⇧F6 to show dialog/)).toBeVisible();
await page.keyboard.type("placed");
await page.keyboard.press("Enter");
const preview = page.getByRole("dialog", { name: "Rename 'order' to 'placed'" });
await expect(preview).toBeVisible();
await expect(preview.getByText("2 occurrences in 1 file")).toBeVisible();
expect(await ipcCalls(page, "apply_workspace_edit")).toHaveLength(0);
await preview.getByRole("button", { name: confirm ? "Do Refactor" : "Cancel", exact: true }).click();
await expect(preview).toBeHidden();
}
await expect.poll(() => ipcCalls(page, "apply_workspace_edit")).toHaveLength(1);
const [apply] = await ipcCalls(page, "apply_workspace_edit");
expect(apply.edit).toMatchObject({ preconditions: [{ path: FILE, hash: "indexed-snapshot" }] });
});
test("diagnostics underline the range and ⌥⏎ applies the preferred quick fix", async ({ workbench: page }) => {
const editor = await openSample(page);
await page.evaluate(
+8
Переглянути файл
@@ -251,6 +251,14 @@ its component with `plugins/build.sh crates/ide-plugin/tests/fixtures/file-templ
aliases and gutter markers) in background batches, not on the editing path. See
[Background document indexers](../docs/plugins/indexing.md).
API 1.3 also uses this graph for Find Usages and Rename on published definitions
and references, alongside language-server results. Name anchors must cover exactly
the name in UTF-16 columns; markers are excluded. Indexed renames always show a
preview and apply as one undoable workspace edit. No new WIT export or capability
is required: the existing `project.read` declaration and trusted-project checks
still apply. Use `engines.openidea = "^1.3"` when relying on this behavior. The
[indexer fixture](../crates/ide-plugin/tests/fixtures/indexer) exercises both flows.
### Activation
Code is instantiated lazily: when one of the plugin's commands runs, or when
+9
Переглянути файл
@@ -27,6 +27,15 @@ and the [minimal fixture](../../../crates/ide-plugin/tests/fixtures/web-pty).
The frame cannot access IPC directly. Its sessions belong to that frame and
end with its view or plugin. There is no new WIT import for this API.
API 1.3 also answers Find Usages and Rename from `DocumentIndexer` definitions
and references. Existing indexers work without another export; declare
`engines.openidea = "^1.3"` when requiring these host features. Each anchor must
cover exactly the name, using UTF-16 columns. The host merges native results,
previews indexed renames and applies them as one undoable workspace edit.
The existing `project.read` capability and trusted-project boundary apply.
See [the indexing contract](../../../docs/plugins/indexing.md#find-usages-and-rename-api-13)
and [the minimal fixture](../../../crates/ide-plugin/tests/fixtures/indexer).
API 1.3 also adds diagnostics for unsaved documents. Declare
`kinds = ["diagnostics"]` and the supported `languages` under
`[[contributes.providers]]`, implement
+5 -1
Переглянути файл
@@ -6616,7 +6616,9 @@ export type ReferencesIncomplete =
/** More project files mention the name than were checked. */
"tooManyFiles" |
/** Files that mention the name were too large or unreadable to check. */
"filesSkipped";
"filesSkipped" |
/** A native query failed; indexed anchors are still available. */
"serverFailed";
/** A registry agent this machine can run (or has installed). */
export type RegistryAgent = {
@@ -6772,6 +6774,8 @@ export type RemoteTarget =
{ kind: "devContainer"; folder: string; config: string };
export type Renamed = {
/** Indexed names always require preview, including a single-file rename. */
preview?: boolean,
edit: WorkspaceEdit,
incomplete: ReferencesIncomplete | null,
};
+2
Переглянути файл
@@ -48,6 +48,8 @@ export function incompleteNote(reason: ReferencesIncomplete, word: string | null
return word
? tx`May be incomplete: too many files mention '${word}' to search them all`
: tx("May be incomplete: too many files mention the name to search them all");
case "serverFailed":
return tx("May be incomplete: the language server could not check this name");
case "filesSkipped":
return word
? tx`May be incomplete: some files that mention '${word}' are too large or unreadable`
+53 -5
Переглянути файл
@@ -14,6 +14,7 @@
import type { EditorView } from "@codemirror/view";
import {
commands,
IpcError,
isCancelled,
isNoServer,
nextRequestId,
@@ -41,6 +42,7 @@ import {
type LocalRenamePlan,
} from "./syntaxFallback";
import { tx } from "@/platform/i18n";
import { synchronizeDocumentIndexes } from "@/workbench/contributions";
async function applyEdit(view: EditorView, edit: WorkspaceEdit) {
try {
@@ -51,6 +53,17 @@ async function applyEdit(view: EditorView, edit: WorkspaceEdit) {
}
}
async function prepareIndexes(view: EditorView, path: string): Promise<boolean> {
const before = view.state.doc;
try {
await synchronizeDocumentIndexes(path);
return view.state.doc === before;
} catch (e) {
reportLspError(view, e, true);
return false;
}
}
async function hasSyntaxErrors(doc: number): Promise<boolean> {
try {
return (await structure.syntaxErrors(doc)).errors.length > 0;
@@ -59,9 +72,16 @@ async function hasSyntaxErrors(doc: number): Promise<boolean> {
}
}
const renamePreparations = new WeakMap<EditorView, object>();
export async function startRename(view: EditorView) {
const doc = docOf(view);
if (!doc) return;
const preparation = {};
renamePreparations.set(view, preparation);
const before = view.state.doc;
const current = () =>
renamePreparations.get(view) === preparation && view.state.doc === before && docOf(view) === doc;
// ⇧F6 while renaming in place: the dialog with more options, keeping what was typed.
const session = inplaceSession(view.state);
if (session) {
@@ -74,14 +94,22 @@ export async function startRename(view: EditorView) {
return;
}
await whenSynced(view);
if (!current()) return;
const head = view.state.selection.main.head;
const pos = offsetToPos(view.state.doc, head);
if (!(await prepareIndexes(view, doc.path)) || !current()) return;
let range;
try {
range = await lspRequest(() => commands.lspPrepareRename(doc.doc, pos));
if (!current()) return;
} catch (e) {
if (!current()) return;
if (isCancelled(e)) return;
if (await startSyntaxRename(view, doc, pos, e)) return;
if (e instanceof IpcError && (e.kind === "outOfSync" || e.kind === "invalidInput")) {
reportLspError(view, e, true);
return;
}
if (await startSyntaxRename(view, doc, pos, e, current)) return;
// No server covers this file (a plain text file): Shift+F6 renames the file, like IDEA.
if (isNoServer(e) && (await executeAction("project.rename"))) return;
reportLspError(view, e, true);
@@ -89,7 +117,9 @@ export async function startRename(view: EditorView) {
}
if (!range) {
// A normal "can't rename" is respected; over broken code the server may just have given up.
if ((await hasSyntaxErrors(doc.doc)) && (await startSyntaxRename(view, doc, pos))) return;
if ((await hasSyntaxErrors(doc.doc)) && current() && (await startSyntaxRename(view, doc, pos, undefined, current)))
return;
if (!current()) return;
showHint(view, "This element can't be renamed");
return;
}
@@ -110,7 +140,7 @@ export async function startRename(view: EditorView) {
// IDEA renames in place by default: occurrences in this file follow live.
const state = view.state;
const others = await linkedOccurrences(view, doc.doc, pos, { from, to }, oldName);
if (view.state.doc !== state.doc) return;
if (view.state.doc !== state.doc || !current()) return;
const keys = shortcutLabel("refactor.rename");
startInplaceRename(view, {
primary: { from, to },
@@ -160,11 +190,16 @@ export async function renameNamePart(view: EditorView, part: Span, replacement:
);
return true;
};
if (!(await prepareIndexes(view, doc.path))) return;
let range;
try {
range = await lspRequest(() => commands.lspPrepareRename(doc.doc, pos));
} catch (e) {
if (isCancelled(e)) return;
if (e instanceof IpcError && (e.kind === "outOfSync" || e.kind === "invalidInput")) {
reportLspError(view, e, true);
return;
}
if (!(await viaSyntax())) refuse("no language server can rename this symbol");
return;
}
@@ -232,9 +267,14 @@ async function finishRename(view: EditorView, doc: EditorDocContext, pos: Pos, o
const request = nextRequestId();
renameRequest = request;
let renamed;
const before = view.state.doc;
try {
await synchronizeDocumentIndexes(doc.path);
if (renameRequest !== request || view.state.doc !== before) return;
renamed = await lspRequest(() => commands.lspRename(request, doc.doc, pos, newName));
if (renameRequest !== request) return;
} catch (e) {
if (renameRequest !== request) return;
reportLspError(view, e, true);
return;
} finally {
@@ -256,7 +296,7 @@ async function finishRename(view: EditorView, doc: EditorDocContext, pos: Pos, o
return;
}
// An edit that may leave usages behind is never applied unseen.
if (!note && !needsPreview(summary)) {
if (!renamed.preview && !note && !needsPreview(summary)) {
await applyEdit(view, edit);
return;
}
@@ -286,15 +326,23 @@ async function syntaxPlan(view: EditorView, doc: EditorDocContext, pos: Pos): Pr
}
/** Opens the rename popup for a syntax-based local rename. False when the fallback can't help. */
async function startSyntaxRename(view: EditorView, doc: EditorDocContext, pos: Pos, cause?: unknown): Promise<boolean> {
async function startSyntaxRename(
view: EditorView,
doc: EditorDocContext,
pos: Pos,
cause: unknown,
current: () => boolean,
): Promise<boolean> {
let result;
try {
await whenSynced(view);
result = await planLocalRename(doc, pos);
} catch (e) {
if (!current()) return true;
if (!isCancelled(e)) console.debug("[syntax] rename fallback", e);
return false;
}
if (!current()) return true;
if (result.kind === "unsupported") return false;
if (result.kind === "refused") {
const reason = isUnavailable(cause) ? unavailableText(cause) : cause instanceof Error ? cause.message : null;
+116 -2
Переглянути файл
@@ -1,5 +1,5 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { EditorState, Text } from "@codemirror/state";
import { EditorState, StateEffect, Text } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { IdeError, Locals, NodeAt, Range, SymbolMatch, SyntaxNode } from "@/bindings";
@@ -38,7 +38,7 @@ vi.mock("@/platform/actions", async (orig) => {
});
const openFile = vi.fn(async () => {});
const { provideEditorService } = await import("@/workbench/contributions");
const { provideEditorService, registerDocumentIndexSync } = await import("@/workbench/contributions");
provideEditorService({ openFile } as never);
const fb = await import("./syntaxFallback");
@@ -47,6 +47,7 @@ const { startRename, renameNamePart } = await import("./rename");
const { lspDoc } = await import("./lsp");
const { invalidateConfigurationNavigation } = await import("./configurationNavigation");
const { useLspPopup, closePopup } = await import("./popupStore");
const { inplaceRenameExtension, inplaceSession } = await import("./inplaceRename");
const r = (line: number, col: number, endCol: number): Range => ({ start: { line, col }, end: { line, col: endCol } });
const node = (kind: string, range: Range, field: string | null = null): SyntaxNode => ({
@@ -462,6 +463,74 @@ describe("rename wiring", () => {
await vi.waitFor(() => expect(cmd.applyWorkspaceEdit).toHaveBeenCalledExactlyOnceWith(edit));
});
it.each(["outOfSync", "invalidInput"] as const)(
"never falls back to a partial local rename after %s preparation",
async (kind) => {
const failed = () =>
Promise.resolve({
status: "error" as const,
error: { kind, message: "Indexed anchors cannot be used" } as IdeError,
});
cmd.lspPrepareRename.mockReturnValueOnce(failed()).mockReturnValueOnce(failed());
const view = editor("old", "typescript", 0);
await startRename(view);
await renameNamePart(view, { from: 0, to: 3 }, "new");
expect(cmd.syntaxLocals).not.toHaveBeenCalled();
expect(cmd.applyWorkspaceEdit).not.toHaveBeenCalled();
expect(view.state.doc.toString()).toBe("old");
},
);
it("keeps a native-owned rename refusal out of the syntax fallback even over broken code", async () => {
cmd.lspPrepareRename.mockReturnValueOnce(ok(r(0, 6, 7)));
cmd.lspRename.mockReturnValueOnce(
Promise.resolve({
status: "error",
error: { kind: "invalidInput", message: "Native server declined rename" } as IdeError,
}),
);
cmd.syntaxErrors.mockReturnValueOnce(ok({ errors: [r(0, 0, 1)], truncated: false }));
cmd.syntaxLocals.mockReturnValueOnce(ok(local()));
const view = editor("const x = 1;\nx;", "typescript", 6);
await renameNamePart(view, { from: 6, to: 7 }, "y");
expect(cmd.syntaxLocals).not.toHaveBeenCalled();
expect(cmd.applyWorkspaceEdit).not.toHaveBeenCalled();
expect(useLspPopup.getState().popup).toBeNull();
expect(view.state.doc.toString()).toBe("const x = 1;\nx;");
});
it("does not fall back to a local-only rename when a contributed index is unavailable", async () => {
const off = registerDocumentIndexSync(async () => {
throw new Error("Index not ready");
});
try {
const view = editor("old", "typescript", 0);
await renameNamePart(view, { from: 0, to: 3 }, "new");
expect(cmd.lspPrepareRename).not.toHaveBeenCalled();
expect(cmd.syntaxLocals).not.toHaveBeenCalled();
expect(cmd.applyWorkspaceEdit).not.toHaveBeenCalled();
expect(view.state.doc.toString()).toBe("old");
} finally {
off();
}
});
it("requires confirmation for a single-file indexed rename and cancellation leaves every anchor unchanged", async () => {
cmd.lspPrepareRename.mockReturnValueOnce(ok(r(0, 0, 3)));
const edit = {
files: [{ kind: "edit", path: "/p/key.idx", edits: [{ range: r(0, 0, 3), new_text: "new" }] }],
preconditions: [{ path: "/p/key.idx", hash: "before" }],
};
cmd.lspRename.mockReturnValueOnce(ok({ edit, incomplete: null, preview: true }));
const view = editor("old", "text", 0);
await renameNamePart(view, { from: 0, to: 3 }, "new");
expect(useLspPopup.getState().popup?.kind).toBe("preview");
expect(cmd.applyWorkspaceEdit).not.toHaveBeenCalled();
closePopup();
expect(view.state.doc.toString()).toBe("old");
expect(cmd.applyWorkspaceEdit).not.toHaveBeenCalled();
});
describe("Typo: Rename to (issue #42)", () => {
it("renames the whole name through the server, references included", async () => {
cmd.lspPrepareRename.mockReturnValueOnce(ok(r(0, 0, 12)));
@@ -540,6 +609,51 @@ describe("rename wiring", () => {
});
});
it.each(["dialog", "cancel"])("does not revive an older preparation after the current %s", async (finish) => {
let release!: (value: Awaited<R<unknown>>) => void;
cmd.lspPrepareRename.mockReturnValueOnce(new Promise((resolve) => (release = resolve)));
cmd.lspPrepareRename.mockReturnValueOnce(ok(r(0, 6, 7)));
const view = editor("const x = 1;", "typescript", 6);
view.dispatch({ effects: StateEffect.appendConfig.of(inplaceRenameExtension()) });
const older = startRename(view);
await vi.waitFor(() => expect(cmd.lspPrepareRename).toHaveBeenCalledTimes(1));
await startRename(view);
expect(inplaceSession(view.state)?.oldName).toBe("x");
await startRename(view);
expect(useLspPopup.getState().popup?.kind).toBe("rename");
if (finish === "cancel") closePopup();
release({ status: "ok", data: r(0, 6, 7) });
await older;
expect(inplaceSession(view.state)).toBeNull();
expect(useLspPopup.getState().popup?.kind ?? null).toBe(finish === "dialog" ? "rename" : null);
});
it.each(["text", "document"])("drops pending preparation when the editor %s changes", async (change) => {
let release!: (value: Awaited<R<unknown>>) => void;
cmd.lspPrepareRename.mockReturnValueOnce(new Promise((resolve) => (release = resolve)));
const view = editor("const x = 1;", "typescript", 6);
view.dispatch({ effects: StateEffect.appendConfig.of(inplaceRenameExtension()) });
const pending = startRename(view);
await vi.waitFor(() => expect(cmd.lspPrepareRename).toHaveBeenCalledTimes(1));
if (change === "text") view.dispatch({ changes: { from: 6, to: 7, insert: "other" } });
else
view.setState(
EditorState.create({
doc: view.state.doc,
extensions: [
lspDoc.of({ doc: 8, path: "/p/other.ts", language: "typescript", uiMode: null, readOnly: false }),
inplaceRenameExtension(),
],
}),
);
const before = view.state.doc.toString();
release({ status: "ok", data: r(0, 6, 7) });
await pending;
expect(inplaceSession(view.state)).toBeNull();
expect(useLspPopup.getState().popup).toBeNull();
expect(view.state.doc.toString()).toBe(before);
});
it("renames the file itself when no server covers it (a plain text file)", async () => {
cmd.lspPrepareRename.mockReturnValueOnce(
Promise.resolve({
+9
Переглянути файл
@@ -52,6 +52,15 @@ describe("Find Usages fallback", () => {
expect(cmd.syntaxLocals).not.toHaveBeenCalled();
});
it("keeps indexed results and names a failed language-server query accurately", async () => {
const usage = { path: "/p/a.ts", range: r(1, 0, 1) };
cmd.lspReferences.mockReturnValueOnce(ok({ locations: [usage], incomplete: "serverFailed" }));
const result = await fetchUsages(caret);
expect(result.groups[0]!.usages).toHaveLength(1);
expect(result.note).toBe("May be incomplete: the language server could not check this name");
expect(cmd.syntaxLocals).not.toHaveBeenCalled();
});
it("searches project-wide under the given request and says when results may be incomplete", async () => {
const usage = { path: "/p/a.ts", range: r(1, 0, 1) };
cmd.lspReferences.mockReturnValueOnce(ok({ locations: [usage], incomplete: "indexing" }));
+44 -1
Переглянути файл
@@ -31,6 +31,8 @@ vi.mock("@tauri-apps/api/core", async (orig) => ({
},
}));
const { registerDocumentIndexSync } = await import("@/workbench/contributions");
const { activeUsageSearch, findUsages, openUsageTab, pinUsageTab, resetUsages, showUsageResults, useUsages } =
await import("./usages");
@@ -93,8 +95,9 @@ describe("findUsages", () => {
const at = (line: number) => ({ path: "/p/b.rs", range: { start: { line, col: 4 }, end: { line, col: 8 } } });
let resolve!: (refs: unknown) => void;
referenceResponse.mockReturnValueOnce(new Promise((r) => (resolve = r)));
const requested = lspReferences.mock.calls.length;
const search = findUsages();
await vi.waitFor(() => expect(channels).not.toHaveLength(0));
await vi.waitFor(() => expect(lspReferences).toHaveBeenCalledTimes(requested + 1));
channels.at(-1)!.onmessage({ locations: [at(1)], incomplete: null });
await vi.waitFor(() => expect(activeUsageSearch(useUsages.getState())!.groups).toHaveLength(1));
expect(activeUsageSearch(useUsages.getState())).toMatchObject({ loading: true, error: null });
@@ -105,6 +108,46 @@ describe("findUsages", () => {
expect(done.groups[0]!.usages.map((u) => u.line)).toEqual([1, 2]);
resetUsages();
});
it("waits for the contributed index barrier, then preserves partial and final usage events", async () => {
let release!: () => void;
const sync = vi.fn(
() =>
new Promise<void>((resolve) => {
release = resolve;
}),
);
const off = registerDocumentIndexSync(sync);
let finish!: (refs: unknown) => void;
referenceResponse.mockReturnValueOnce(
new Promise((resolve) => {
finish = resolve;
}),
);
lspReferences.mockClear();
const allocated = channels.length;
const pending = findUsages();
const at = (line: number) => ({ path: "/p/b.rs", range: { start: { line, col: 4 }, end: { line, col: 8 } } });
try {
await vi.waitFor(() => expect(sync).toHaveBeenCalledWith("/p/a.rs"));
expect(lspReferences).not.toHaveBeenCalled();
expect(channels).toHaveLength(allocated);
release();
await vi.waitFor(() => expect(lspReferences).toHaveBeenCalledTimes(1));
channels.at(-1)!.onmessage({ locations: [at(1)], incomplete: null });
await vi.waitFor(() => expect(activeUsageSearch(useUsages.getState())!.groups).toHaveLength(1));
expect(activeUsageSearch(useUsages.getState())!.loading).toBe(true);
finish({ locations: [at(1), at(2)], incomplete: null });
await pending;
expect(activeUsageSearch(useUsages.getState())!.groups[0]!.usages.map((u) => u.line)).toEqual([1, 2]);
} finally {
off();
release?.();
finish?.({ locations: [], incomplete: null });
await pending;
resetUsages();
}
});
});
describe("usage tabs", () => {
+5 -2
Переглянути файл
@@ -4,9 +4,9 @@ import { Channel } from "@tauri-apps/api/core";
import type { ClassifiedReference, ReferenceAccess, ReferenceKind, Location, References, RequestId } from "@/bindings";
import { clearLineTokens } from "@/editor/lineTokens";
import { incompleteNote } from "@/editor/lsp/lsp";
import { commands, isCancelled, nextRequestId } from "@/platform/ipc";
import { commands, IpcError, isCancelled, nextRequestId } from "@/platform/ipc";
import { shortcutLabel } from "@/platform/keymap";
import { toolWindowService } from "@/workbench/contributions";
import { synchronizeDocumentIndexes, toolWindowService } from "@/workbench/contributions";
import {
caretContext,
caretPoint,
@@ -222,10 +222,13 @@ export async function fetchUsages(
): Promise<FetchedUsages> {
// Usage lines are read afresh: so are the files their syntax colours come from.
clearLineTokens();
const before = c.view?.state.doc;
let locs: Location[];
let classified: ClassifiedReference[] = [];
let note: string | null = null;
let librariesSkipped = false;
await synchronizeDocumentIndexes(c.path);
if (c.view && c.view.state.doc !== before) throw new IpcError({ kind: "cancelled", message: "", data: null });
let settled = false;
const channel = new Channel<References>();
channel.onmessage = (refs) => {
+15 -3
Переглянути файл
@@ -236,8 +236,12 @@ it("opens with the usages found first and fills in the rest", async () => {
}),
);
const before = calls.length;
const requested = lspReferences.mock.calls.length;
const pending = showUsagesPopup();
await act(async () => channels.at(-1)!.onmessage({ locations: [at(1)], incomplete: null }));
await act(async () => {
await vi.waitFor(() => expect(lspReferences).toHaveBeenCalledTimes(requested + 1));
channels.at(-1)!.onmessage({ locations: [at(1)], incomplete: null });
});
// Not before the rest had a moment to arrive.
expect(useUsagesPopup.getState().open).toBe(false);
await act(() => new Promise((r) => setTimeout(r, 350)));
@@ -268,8 +272,12 @@ it("jumps to a single usage when all arrive quickly, and otherwise stays open",
);
respond();
let before = calls.length;
let requested = lspReferences.mock.calls.length;
let pending = showUsagesPopup();
await act(async () => channels.at(-1)!.onmessage({ locations: [loc], incomplete: null }));
await act(async () => {
await vi.waitFor(() => expect(lspReferences).toHaveBeenCalledTimes(requested + 1));
channels.at(-1)!.onmessage({ locations: [loc], incomplete: null });
});
await act(async () => {
resolve({ locations: [loc], incomplete: null });
await pending;
@@ -278,8 +286,12 @@ it("jumps to a single usage when all arrive quickly, and otherwise stays open",
expect(calls.slice(before)).toEqual(["open /p/b.ts:1", "hint Only one usage in Project Files"]);
respond();
before = calls.length;
requested = lspReferences.mock.calls.length;
pending = showUsagesPopup();
await act(async () => channels.at(-1)!.onmessage({ locations: [loc], incomplete: null }));
await act(async () => {
await vi.waitFor(() => expect(lspReferences).toHaveBeenCalledTimes(requested + 1));
channels.at(-1)!.onmessage({ locations: [loc], incomplete: null });
});
await act(() => new Promise((r) => setTimeout(r, 350)));
await act(async () => {
resolve({ locations: [loc], incomplete: null });
+61
Переглянути файл
@@ -5,6 +5,7 @@ import { useProject } from "@/platform/project";
import { usePlugins } from "./store";
import { useEditorRegistry } from "@/editor/api";
import { lspDoc } from "@/editor/lsp/lsp";
import { synchronizeDocumentIndexes } from "@/workbench/contributions";
const mocks = vi.hoisted(() => ({
files: vi.fn(),
@@ -128,3 +129,63 @@ it("keeps warm indexes when API 1.0 reclaims an idle Wasm instance", async () =>
expect(mocks.files).toHaveBeenCalledTimes(1);
expect(mocks.index).not.toHaveBeenCalled();
});
it("flushes pending changes before usages or rename without waiting for the debounce", async () => {
off = startPluginNavigation();
await vi.advanceTimersByTimeAsync(250);
mocks.index.mockClear();
mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.xml"], removed: [] });
await synchronizeDocumentIndexes("/project/A.xml");
expect(mocks.index).toHaveBeenCalledExactlyOnceWith(["/project/A.xml"], expect.any(Number));
});
it("waits for an active batch, then indexes changes queued while it was running", async () => {
off = startPluginNavigation();
await vi.advanceTimersByTimeAsync(250);
let resolve!: (result: { status: "ok"; data: null }) => void;
mocks.index.mockImplementationOnce(
() =>
new Promise((done) => {
resolve = done;
}),
);
mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.xml"], removed: [] });
await vi.advanceTimersByTimeAsync(40);
mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.java"], removed: [] });
let settled = false;
const waiting = synchronizeDocumentIndexes("/project/A.xml").then(() => {
settled = true;
});
await Promise.resolve();
expect(settled).toBe(false);
resolve({ status: "ok", data: null });
await waiting;
expect(mocks.index).toHaveBeenLastCalledWith(["/project/A.java"], expect.any(Number));
});
it("refuses refactoring if a queued index batch failed", async () => {
off = startPluginNavigation();
await vi.advanceTimersByTimeAsync(250);
mocks.index.mockResolvedValueOnce({ status: "error", error: { kind: "unavailable", message: "indexer failed" } });
mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.xml"], removed: [] });
await expect(synchronizeDocumentIndexes("/project/A.xml")).rejects.toThrow("Plugin indexes are still updating");
});
it.each(["/project/notes.txt", "/external/Other.xml", "jdt://contents/Other.xml"])(
"does not block unowned %s while a plugin batch is running",
async (path) => {
off = startPluginNavigation();
await vi.advanceTimersByTimeAsync(250);
mocks.index.mockImplementationOnce(() => new Promise(() => {}));
mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.xml"], removed: [] });
await vi.advanceTimersByTimeAsync(40);
await synchronizeDocumentIndexes(path);
},
);
it("keeps unrelated language-server queries available when plugin discovery fails", async () => {
mocks.files.mockRejectedValue(new Error("Plugin file inventory is unavailable"));
off = startPluginNavigation();
await expect(synchronizeDocumentIndexes("/project/notes.txt")).resolves.toBeUndefined();
expect(mocks.index).not.toHaveBeenCalled();
});
+38 -3
Переглянути файл
@@ -7,7 +7,8 @@ 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 { notifications, registerDocumentIndexSync } from "@/workbench/contributions";
import { tx } from "@/platform/i18n";
import { pluginsBackend } from "./backend";
import { invalidatePluginMarkers } from "./markerStore";
import { usePlugins } from "./store";
@@ -53,7 +54,16 @@ export function startPluginNavigation(): () => void {
);
const notify = () => invalidateNavigationProviders();
async function flush() {
let flushing: Promise<void> | null = null;
function flush(): Promise<void> {
if (flushing) return flushing;
const task = runFlush().finally(() => {
if (flushing === task) flushing = null;
});
flushing = task;
return task;
}
async function runFlush() {
timer = undefined;
if (running || !ready() || !pending.size) return;
running = true;
@@ -92,7 +102,16 @@ export function startPluginNavigation(): () => void {
clearTimeout(timer);
timer = setTimeout(() => void flush(), priority ? 150 : 30);
}
async function discover() {
let discovery: Promise<void> | null = null;
function discover(): Promise<void> {
if (discovery) return discovery;
const task = runDiscovery().finally(() => {
if (discovery === task) discovery = null;
});
discovery = task;
return task;
}
async function runDiscovery() {
if (!ready() || discovered || discovering) return;
discovering = true;
const at = generation;
@@ -123,6 +142,7 @@ export function startPluginNavigation(): () => void {
known.clear();
discovered = false;
discovering = false;
discovery = null;
reportedError = false;
notify();
if (ready())
@@ -131,6 +151,21 @@ export function startPluginNavigation(): () => void {
.catch(() => {});
}
const offs = [
registerDocumentIndexSync(async (path) => {
if (!ready()) return;
const root = useProject.getState().project!.root;
if (!path.startsWith(root.endsWith("/") ? root : `${root}/`)) return;
const at = generation;
await discover();
if (!eligible(path)) return;
while (ready() && at === generation && (pending.size || flushing)) {
clearTimeout(timer);
await flush();
}
if (at !== generation || !discovered || dirty.size) {
throw new Error(tx`Plugin indexes are still updating; try again shortly`);
}
}),
pluginsBackend().onEvent((event) => {
if (event.type === "documentIndexesChanged") reset();
}),
+8
Переглянути файл
@@ -1,4 +1,12 @@
{
"Native server declined rename": "Native server declined rename",
"document is too large or is not a regular file": "document is too large or is not a regular file",
"document is too large": "document is too large",
"decoded document is too large": "decoded document is too large",
"May be incomplete: the language server could not check this name": "May be incomplete: the language server could not check this name",
"indexed document changed; wait for indexing and try again": "indexed document changed; wait for indexing and try again",
"indexed name is not on a UTF-16 character boundary": "indexed name is not on a UTF-16 character boundary",
"Plugin indexes are still updating; try again shortly": "Plugin indexes are still updating; try again shortly",
"CMake Configure": "CMake Configure",
"CMake Build": "CMake Build",
"CTest": "CTest",
+8
Переглянути файл
@@ -1,4 +1,12 @@
{
"Native server declined rename": "语言服务器拒绝重命名",
"document is too large or is not a regular file": "文档过大或不是普通文件",
"document is too large": "文档过大",
"decoded document is too large": "解码后的文档过大",
"May be incomplete: the language server could not check this name": "结果可能不完整:语言服务器无法检查此名称",
"indexed document changed; wait for indexing and try again": "索引文档已更改,请等待索引完成后重试",
"indexed name is not on a UTF-16 character boundary": "索引名称未位于 UTF-16 字符边界上",
"Plugin indexes are still updating; try again shortly": "插件索引仍在更新,请稍后重试",
"CMake Configure": "CMake 配置",
"CMake Build": "CMake 构建",
"CTest": "CTest 测试",
+12
Переглянути файл
@@ -1681,3 +1681,15 @@ export function addContributedFileTemplate(t: import("@/platform/fileTemplates")
useContributedFileTemplates.setState((s) => ({ templates: [...s.templates.filter((x) => x.id !== t.id), t] }));
return () => useContributedFileTemplates.setState((s) => ({ templates: s.templates.filter((x) => x !== t) }));
}
/** Flush contributed document indexes before workspace usages or refactoring. */
const documentIndexSync = new Set<(path: string) => Promise<void>>();
export function registerDocumentIndexSync(sync: (path: string) => Promise<void>): () => void {
documentIndexSync.add(sync);
return () => {
documentIndexSync.delete(sync);
};
}
export async function synchronizeDocumentIndexes(path: string): Promise<void> {
await Promise.all([...documentIndexSync].map((sync) => sync(path)));
}