дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
63065bab64
коміт
2329f1e955
30 files changed
+1582
-62
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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`) ----
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
190f1d506b854c3dc0ade563b8cda2488767522d4fd653427513ea9d1d83c989
|
||||
3463e906eff16c16822848fc02cbf5c8c2282f0ae7849b3a9246b4671eb6b297
|
||||
@@ -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");
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
}),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 测试",
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача