From 2329f1e955b9dc6707c0c8e2b23c8c66c6aa829c Mon Sep 17 00:00:00 2001 From: Arnaud Ritti <77437157+arnaud-ritti@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:40:08 +0100 Subject: [PATCH] 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 <77437157+arnaud-ritti@users.noreply.github.com> --- crates/ide-host/src/ops/lsp.rs | 482 +++++++++++++++++- crates/ide-host/src/plugins/index_ops.rs | 17 +- crates/ide-host/src/plugins/navigation.rs | 90 +++- crates/ide-host/tests/plugin_indexing.rs | 54 ++ crates/ide-host/tests/plugin_refactoring.rs | 394 ++++++++++++++ crates/ide-lsp/src/lib.rs | 6 +- crates/ide-lsp/src/types.rs | 6 + .../tests/fixtures/indexer/README.md | 24 + .../tests/fixtures/indexer/dist/plugin.wasm | Bin 149938 -> 149938 bytes .../fixtures/indexer/dist/plugin.wasm.sources | 2 +- crates/ide-vfs/src/encoding.rs | 78 +++ crates/ide-vfs/src/lib.rs | 5 + crates/ide-workspace/src/documents.rs | 10 + docs/plugins/indexing.md | 29 ++ e2e/codeInsight.spec.ts | 37 ++ plugins/README.md | 8 + sdk/rust/openidea-plugin/README.md | 9 + src/bindings.ts | 6 +- src/editor/lsp/lsp.ts | 2 + src/editor/lsp/rename.ts | 58 ++- src/editor/lsp/syntaxFallback.test.ts | 118 ++++- .../code-insight/syntaxUsages.test.ts | 9 + .../code-insight/usageResults.test.ts | 45 +- src/features/code-insight/usages.ts | 7 +- .../code-insight/usagesPopup.test.tsx | 18 +- src/features/plugins/navigation.test.ts | 61 +++ src/features/plugins/navigation.ts | 41 +- src/platform/locales/text/en.json | 8 + src/platform/locales/text/zh.json | 8 + src/workbench/contributions.ts | 12 + 30 files changed, 1582 insertions(+), 62 deletions(-) create mode 100644 crates/ide-host/tests/plugin_refactoring.rs diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index c17c4c387..a77cbfcfe 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -396,25 +396,144 @@ pub async fn lsp_references( libraries: bool, on_partial: super::Channel, ) -> IdeResult { - 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> { + 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> { + 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> { 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> { + 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![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)>, data: Option<&str>| ide_plugin::CodeAction { diff --git a/crates/ide-host/src/plugins/index_ops.rs b/crates/ide-host/src/plugins/index_ops.rs index a67127980..d4037c9f9 100644 --- a/crates/ide-host/src/plugins/index_ops.rs +++ b/crates/ide-host/src/plugins/index_ops.rs @@ -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) }; diff --git a/crates/ide-host/src/plugins/navigation.rs b/crates/ide-host/src/plugins/navigation.rs index efa8ef5db..7b1cc340f 100644 --- a/crates/ide-host/src/plugins/navigation.rs +++ b/crates/ide-host/src/plugins/navigation.rs @@ -28,6 +28,7 @@ pub use ide_plugin::indexing::*; struct File { version: Option, 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 { + if !(self.project_policy.read())(root) { + return None; + } + let plugins = self.plugins.read(); + let index = self.navigation.lock(); + let graphs = index.projects.get(&(root.to_owned(), client.map(str::to_owned)))?; + let mut result: Option = None; + for (id, graph) in graphs { + let Some(plugin) = plugins.get(id) else { continue }; + if !plugin.status().is_live() || !graph.instance.ptr_eq(&Arc::downgrade(plugin)) { + continue; + } + let Some(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, +} + +#[derive(Clone)] +pub struct IndexedAnchors { + pub path: PathBuf, + pub version: Option, + pub hash: blake3::Hash, + pub ranges: Vec, +} + +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 { diff --git a/crates/ide-host/tests/plugin_indexing.rs b/crates/ide-host/tests/plugin_indexing.rs index 045531a54..182bae672 100644 --- a/crates/ide-host/tests/plugin_indexing.rs +++ b/crates/ide-host/tests/plugin_indexing.rs @@ -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::(), 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::(), 3); + } +} diff --git a/crates/ide-host/tests/plugin_refactoring.rs b/crates/ide-host/tests/plugin_refactoring.rs new file mode 100644 index 000000000..2b9a678e0 --- /dev/null +++ b/crates/ide-host/tests/plugin_refactoring.rs @@ -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); + impl Handler for Native { + fn on_notification(&self, _: &str, _: Value) {} + fn on_request(&self, method: &str, _: Value) -> Result { + 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::::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(); + } +} diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index a7e09ed27..7b8dda66e 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -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`) ---- diff --git a/crates/ide-lsp/src/types.rs b/crates/ide-lsp/src/types.rs index 2b6013c83..9c629632c 100644 --- a/crates/ide-lsp/src/types.rs +++ b/crates/ide-lsp/src/types.rs @@ -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, } diff --git a/crates/ide-plugin/tests/fixtures/indexer/README.md b/crates/ide-plugin/tests/fixtures/indexer/README.md index fa06eefa7..38b07047a 100644 --- a/crates/ide-plugin/tests/fixtures/indexer/README.md +++ b/crates/ide-plugin/tests/fixtures/indexer/README.md @@ -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. diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm index 432b5deb479f3dc108a75ded3a4ac2ba4b540c7a..90b981b2f64e53ba318acc620c9618254bedfbfc 100644 GIT binary patch delta 194 zcmdlqnRC-*&JEXuCHMR~dTslR+q1P=6__=dIh-CXxwCuv*Sj~|H@_D?$%se#q9`jq z>Gxu*IgxcUq8L8;l%m( Jg;y8_O97XeWKjSB delta 194 zcmdlqnRC-*&JEXuC3h`2e|ProXLl-E6__=dIh>Bnxcgzj-skT*H@_D?$%se#q9`jq z>Gxu*IgxcUq8L8;l%m( IdeResult { }) } +/// 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 { + 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 { @@ -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"); diff --git a/crates/ide-vfs/src/lib.rs b/crates/ide-vfs/src/lib.rs index a7279af99..55cc39ec0 100644 --- a/crates/ide-vfs/src/lib.rs +++ b/crates/ide-vfs/src/lib.rs @@ -204,6 +204,11 @@ pub fn load_text(path: &Path) -> IdeResult { 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 { + 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 { diff --git a/crates/ide-workspace/src/documents.rs b/crates/ide-workspace/src/documents.rs index 577b626da..e8801bd11 100644 --- a/crates/ide-workspace/src/documents.rs +++ b/crates/ide-workspace/src/documents.rs @@ -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 { + 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 { Self::load_as(path, encoding, self.native_to_ascii_for(path)) } diff --git a/docs/plugins/indexing.md b/docs/plugins/indexing.md index af146dd7c..5262637bd 100644 --- a/docs/plugins/indexing.md +++ b/docs/plugins/indexing.md @@ -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` diff --git a/e2e/codeInsight.spec.ts b/e2e/codeInsight.spec.ts index 851297410..1ddcd4796 100644 --- a/e2e/codeInsight.spec.ts +++ b/e2e/codeInsight.spec.ts @@ -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( diff --git a/plugins/README.md b/plugins/README.md index de045e921..2ecf64836 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -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 diff --git a/sdk/rust/openidea-plugin/README.md b/sdk/rust/openidea-plugin/README.md index 7d24498f7..d6dc1750b 100644 --- a/sdk/rust/openidea-plugin/README.md +++ b/sdk/rust/openidea-plugin/README.md @@ -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 diff --git a/src/bindings.ts b/src/bindings.ts index 8f21ba196..ccd7236ff 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -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, }; diff --git a/src/editor/lsp/lsp.ts b/src/editor/lsp/lsp.ts index ff4d49848..8f76509a5 100644 --- a/src/editor/lsp/lsp.ts +++ b/src/editor/lsp/lsp.ts @@ -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` diff --git a/src/editor/lsp/rename.ts b/src/editor/lsp/rename.ts index 7a5ec9baa..c17296efd 100644 --- a/src/editor/lsp/rename.ts +++ b/src/editor/lsp/rename.ts @@ -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 { + 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 { try { return (await structure.syntaxErrors(doc)).errors.length > 0; @@ -59,9 +72,16 @@ async function hasSyntaxErrors(doc: number): Promise { } } +const renamePreparations = new WeakMap(); + 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 { +async function startSyntaxRename( + view: EditorView, + doc: EditorDocContext, + pos: Pos, + cause: unknown, + current: () => boolean, +): Promise { 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; diff --git a/src/editor/lsp/syntaxFallback.test.ts b/src/editor/lsp/syntaxFallback.test.ts index 752320bf7..dfa841812 100644 --- a/src/editor/lsp/syntaxFallback.test.ts +++ b/src/editor/lsp/syntaxFallback.test.ts @@ -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>) => 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>) => 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({ diff --git a/src/features/code-insight/syntaxUsages.test.ts b/src/features/code-insight/syntaxUsages.test.ts index f1957318e..aa25447f0 100644 --- a/src/features/code-insight/syntaxUsages.test.ts +++ b/src/features/code-insight/syntaxUsages.test.ts @@ -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" })); diff --git a/src/features/code-insight/usageResults.test.ts b/src/features/code-insight/usageResults.test.ts index 7316b862b..60d501ae8 100644 --- a/src/features/code-insight/usageResults.test.ts +++ b/src/features/code-insight/usageResults.test.ts @@ -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((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", () => { diff --git a/src/features/code-insight/usages.ts b/src/features/code-insight/usages.ts index a1a8d6d10..eb2e9fad7 100644 --- a/src/features/code-insight/usages.ts +++ b/src/features/code-insight/usages.ts @@ -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 { // 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(); channel.onmessage = (refs) => { diff --git a/src/features/code-insight/usagesPopup.test.tsx b/src/features/code-insight/usagesPopup.test.tsx index 9b7b15d55..1e2870396 100644 --- a/src/features/code-insight/usagesPopup.test.tsx +++ b/src/features/code-insight/usagesPopup.test.tsx @@ -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 }); diff --git a/src/features/plugins/navigation.test.ts b/src/features/plugins/navigation.test.ts index df603bd8d..ce473a49e 100644 --- a/src/features/plugins/navigation.test.ts +++ b/src/features/plugins/navigation.test.ts @@ -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(); +}); diff --git a/src/features/plugins/navigation.ts b/src/features/plugins/navigation.ts index ebf44bc96..827a7ddb2 100644 --- a/src/features/plugins/navigation.ts +++ b/src/features/plugins/navigation.ts @@ -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 | null = null; + function flush(): Promise { + 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 | null = null; + function discover(): Promise { + 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(); }), diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index ec3356e02..776b566f9 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -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", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 132733ed6..5dd839a3f 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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 测试", diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index 8e77311da..f8b5be248 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -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>(); +export function registerDocumentIndexSync(sync: (path: string) => Promise): () => void { + documentIndexSync.add(sync); + return () => { + documentIndexSync.delete(sync); + }; +} +export async function synchronizeDocumentIndexes(path: string): Promise { + await Promise.all([...documentIndexSync].map((sync) => sync(path))); +}