diff --git a/crates/hir/src/semantics/pathres.rs b/crates/hir/src/semantics/pathres.rs index acdb344a..56faf4d0 100644 --- a/crates/hir/src/semantics/pathres.rs +++ b/crates/hir/src/semantics/pathres.rs @@ -3,10 +3,13 @@ use syntax::{SyntaxNode, SyntaxTokenWithParent}; use super::SemanticsImpl; use crate::{ container::{ - ContainerId, InBlock, InContainer, InFile, InGenerateBlock, InModule, InSubroutine, + ContainerId, ContainerParent, InBlock, InContainer, InFile, InGenerateBlock, InModule, + InSubroutine, }, + db::HirDb, file::HirFileId, hir_def::{ + Ident, block::BlockId, expr::declarator::DeclId, file::{config::ConfigDeclId, library::LibraryDeclId, udp::UdpDeclId}, @@ -37,6 +40,32 @@ impl SemanticsImpl<'_> { pub(in crate::semantics) fn find_container(&self, node: InFile) -> ContainerId { self.with_ctx(|ctx| ctx.find_container(node)) } + + pub fn resolve_name(&self, cont_id: ContainerId, ident: &Ident) -> Option { + resolve_name(self.db, cont_id, ident) + } +} + +pub fn resolve_name(db: &dyn HirDb, cont_id: ContainerId, ident: &Ident) -> Option { + ContainerParent::start_from(db, cont_id).find_map(|id| match id { + ContainerId::HirFileId(_) => db.unit_scope().get(ident).map(PathResolution::from), + ContainerId::ModuleId(module_id) => db + .module_scope(module_id) + .get(ident) + .map(|entry| PathResolution::from(InModule::new(module_id, entry))), + ContainerId::GenerateBlockId(generate_block_id) => db + .generate_block_scope(generate_block_id) + .get(ident) + .map(|entry| PathResolution::from(InGenerateBlock::new(generate_block_id, entry))), + ContainerId::BlockId(block_id) => db + .block_scope(block_id) + .get(ident) + .map(|entry| PathResolution::from(InBlock::new(block_id, entry))), + ContainerId::SubroutineId(subroutine_id) => db + .subroutine_scope(subroutine_id) + .get(ident) + .map(|entry| PathResolution::from(InSubroutine::new(subroutine_id, entry))), + }) } #[derive(Debug, Copy, Clone, PartialEq, Eq)] diff --git a/crates/hir/src/type_infer.rs b/crates/hir/src/type_infer.rs index 28a8a6c1..2fbb0337 100644 --- a/crates/hir/src/type_infer.rs +++ b/crates/hir/src/type_infer.rs @@ -2,9 +2,7 @@ use rustc_hash::FxHashSet; use utils::get::GetRef; use crate::{ - container::{ - ContainerId, ContainerParent, InContainer, InGenerateBlock, InModule, InSubroutine, - }, + container::{ContainerId, InContainer, InGenerateBlock, InModule, InSubroutine}, db::HirDb, hir_def::{ Ident, @@ -21,7 +19,7 @@ use crate::{ subroutine::SubroutinePortId, typedef::TypedefId, }, - semantics::pathres::PathResolution, + semantics::pathres::{PathResolution, resolve_name}, }; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -424,28 +422,6 @@ fn type_of_subroutine_port(db: &dyn HirDb, port: InSubroutine) .unwrap_or_else(|| TyResult::new(Ty::Unknown)) } -fn resolve_name(db: &dyn HirDb, cont_id: ContainerId, ident: &Ident) -> Option { - ContainerParent::start_from(db, cont_id).find_map(|id| match id { - ContainerId::HirFileId(_) => db.unit_scope().get(ident).map(PathResolution::from), - ContainerId::ModuleId(module_id) => db - .module_scope(module_id) - .get(ident) - .map(|entry| PathResolution::from(InModule::new(module_id, entry))), - ContainerId::GenerateBlockId(generate_block_id) => db - .generate_block_scope(generate_block_id) - .get(ident) - .map(|entry| PathResolution::from(InGenerateBlock::new(generate_block_id, entry))), - ContainerId::BlockId(block_id) => db - .block_scope(block_id) - .get(ident) - .map(|entry| PathResolution::from(crate::container::InBlock::new(block_id, entry))), - ContainerId::SubroutineId(subroutine_id) => db - .subroutine_scope(subroutine_id) - .get(ident) - .map(|entry| PathResolution::from(InSubroutine::new(subroutine_id, entry))), - }) -} - fn instance_target_module_id( db: &dyn HirDb, module_id: ModuleId, diff --git a/crates/ide/src/analysis.rs b/crates/ide/src/analysis.rs index 0edff044..f7fa83e8 100644 --- a/crates/ide/src/analysis.rs +++ b/crates/ide/src/analysis.rs @@ -188,6 +188,33 @@ impl Analysis { self.with_db(|db| rename::rename(db, position, config, new_name)) } + pub fn rename_expansion_info( + &self, + position: FilePosition, + config: RenameConfig, + ) -> Cancellable> { + self.with_db(|db| rename::rename_expansion_info(db, position, config)) + } + + pub fn expanded_rename( + &self, + position: FilePosition, + config: RenameConfig, + new_name: &str, + ) -> Cancellable> { + self.with_db(|db| rename::expanded_rename(db, position, config, new_name)) + } + + pub fn rename_conflict_info( + &self, + position: FilePosition, + config: RenameConfig, + new_name: &str, + recursive: bool, + ) -> Cancellable> { + self.with_db(|db| rename::rename_conflict_info(db, position, config, new_name, recursive)) + } + pub fn format( &self, file_id: FileId, diff --git a/crates/ide/src/rename.rs b/crates/ide/src/rename.rs index b07f3cb5..04cef1b4 100644 --- a/crates/ide/src/rename.rs +++ b/crates/ide/src/rename.rs @@ -1,9 +1,12 @@ use hir::{ base_db::source_db::SourceDb, container::InFile, hir_def::lower_ident, semantics::Semantics, }; +use nohash_hasher::IntMap; +use rustc_hash::FxHashMap; +use smol_str::SmolStr; use syntax::{ SyntaxAncestors, SyntaxNode, SyntaxNodeExt, SyntaxTokenWithParent, - ast::{self, AstNode}, + ast::{self, AstNode, Expression, Name}, has_text_range::{HasTextRange, HasTextRangeIn}, match_ast, token::TokenKindExt, @@ -12,13 +15,14 @@ use thiserror::Error; use utils::{ line_index::{TextRange, TextSize}, text_edit::TextEdit, + uniq_vec::UniqVec, }; use vfs::FileId; use crate::{ FilePosition, ScopeVisibility, db::root_db::RootDb, - definitions::{Definition, DefinitionClass}, + definitions::{Definition, DefinitionClass, DefinitionOrigin}, references::{ ReferencesConfig, search::{ReferenceToken, ReferencesCtx, SearchScope}, @@ -86,6 +90,16 @@ pub enum RenameError { ProjectScopeRequired, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecursiveRenameInfo { + pub additional_symbols: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RenameCollisionInfo { + pub conflicts: usize, +} + pub(crate) fn prepare_rename( db: &RootDb, FilePosition { file_id, offset }: FilePosition, @@ -97,31 +111,214 @@ pub(crate) fn prepare_rename( let root = parsed_file.root().ok_or(RenameError::NoRefFound)?; let token = pick_token(root, offset)?; let text_range = token.text_range().ok_or(RenameError::NoRefFound)?; - let def = resolve_rename_definition(&sema, hir_file_id, token)?; + let def = + match DefinitionClass::resolve(&sema, hir_file_id, token).ok_or(RenameError::NoDefFound)? { + DefinitionClass::Definition(def) => def, + DefinitionClass::PortConnShorthand { local, .. } => local, + DefinitionClass::Ambiguous(_) => return Err(RenameError::NoDefFound), + }; let _ = config.references_config(db, &def, file_id)?; Ok(text_range) } pub(crate) fn rename( db: &RootDb, - FilePosition { file_id, offset }: FilePosition, + position @ FilePosition { file_id, .. }: FilePosition, config: RenameConfig, new_name: &str, ) -> RenameResult { let sema = Semantics::new(db); - let hir_file_id = file_id.into(); + let ResolvedRenameTarget { selected_def, .. } = resolve_rename_target(&sema, position)?; + rename_definition(db, &sema, file_id, &config, &selected_def, new_name, None) +} + +pub(crate) fn rename_expansion_info( + db: &RootDb, + position: FilePosition, + config: RenameConfig, +) -> RenameResult { + let sema = Semantics::new(db); + let resolved = resolve_rename_target(&sema, position)?; + let targets = recursive_rename_targets(db, &sema, position.file_id, &config, resolved.targets)?; + let additional_symbols = targets.len().saturating_sub(1); + Ok(RecursiveRenameInfo { additional_symbols }) +} + +pub(crate) fn expanded_rename( + db: &RootDb, + position: FilePosition, + config: RenameConfig, + new_name: &str, +) -> RenameResult { + let sema = Semantics::new(db); + let resolved = resolve_rename_target(&sema, position)?; + let targets = recursive_rename_targets(db, &sema, position.file_id, &config, resolved.targets)?; + let mut rename_targets = UniqVec::<(), DefinitionOrigin>::default(); + for target in &targets { + rename_targets.push(target.def.origins(), ()); + } + let mut source_changes = SourceChange::default(); + + for target in &targets { + let changes = rename_definition_with_refs( + db, + &sema, + &target.def, + new_name, + Some(&rename_targets), + &target.refs, + &target.same_name_refs, + )?; + for (file_id, edit) in changes.text_edits { + source_changes + .insert_text_edit(file_id, edit) + .map_err(|_| RenameError::OverlappingEdits)?; + } + } + + Ok(source_changes) +} + +pub(crate) fn rename_conflict_info( + db: &RootDb, + position: FilePosition, + config: RenameConfig, + new_name: &str, + recursive: bool, +) -> RenameResult { + let sema = Semantics::new(db); + let resolved = resolve_rename_target(&sema, position)?; + let targets: Vec = if recursive { + recursive_rename_targets(db, &sema, position.file_id, &config, resolved.targets)? + .into_iter() + .map(|target| target.def) + .collect() + } else { + vec![resolved.selected_def] + }; + + let new_name = SmolStr::new(new_name); + let mut target_index = UniqVec::<(), DefinitionOrigin>::default(); + for target in &targets { + target_index.push(target.origins(), ()); + } + let mut conflicts = UniqVec::::default(); + for collision in targets.iter().flat_map(|target| target.origins()).filter_map(|origin| { + sema.resolve_name(origin.container_id(db), &new_name).map(Definition::from) + }) { + if collision.origins().iter().any(|origin| target_index.contains(origin)) { + continue; + } + conflicts.push(collision.origins(), collision); + } + + Ok(RenameCollisionInfo { conflicts: conflicts.len() }) +} + +struct ResolvedRenameTarget { + selected_def: Definition, + targets: Vec, +} + +type ReferenceSearchResult = IntMap>; + +struct RecursiveRenameTarget { + def: Definition, + refs: ReferenceSearchResult, + same_name_refs: Vec, +} + +fn resolve_rename_target( + sema: &Semantics<'_, RootDb>, + FilePosition { file_id, offset }: FilePosition, +) -> RenameResult { let parsed_file = sema.parse_file(file_id); let root = parsed_file.root().ok_or(RenameError::NoRefFound)?; let token = pick_token(root, offset)?; - let def = resolve_rename_definition(&sema, hir_file_id, token)?; - let refs_config = config.references_config(db, &def, file_id)?; + let mut targets = UniqVec::::default(); + let selected_def = match DefinitionClass::resolve(sema, file_id.into(), token) + .ok_or(RenameError::NoDefFound)? + { + DefinitionClass::Definition(def) => { + targets.push(def.origins(), def.clone()); + def + } + DefinitionClass::PortConnShorthand { port, local } => { + targets.push(local.origins(), local.clone()); + targets.push(port.origins(), port); + local + } + DefinitionClass::Ambiguous(_) => return Err(RenameError::NoDefFound), + }; + Ok(ResolvedRenameTarget { selected_def, targets: targets.into_vec() }) +} - let old_name = lower_ident(Some(token.tok)).ok_or(RenameError::NoRefFound)?; - let mut source_changes = SourceChange::default(); - ReferencesCtx::new(&sema, &def, refs_config) - .search() +#[derive(Debug, Clone, PartialEq, Eq)] +struct SameNameConnection { + port: Definition, + local: Definition, + collapse_range: TextRange, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SameNameConnectionRef { + file_id: FileId, + range: TextRange, + conn: SameNameConnection, +} + +fn rename_definition( + db: &RootDb, + sema: &Semantics<'_, RootDb>, + request_file_id: FileId, + config: &RenameConfig, + def: &Definition, + new_name: &str, + rename_targets: Option<&UniqVec<(), DefinitionOrigin>>, +) -> RenameResult { + let refs = references_for_definition(db, sema, request_file_id, config, def)?; + rename_definition_with_refs(db, sema, def, new_name, rename_targets, &refs, &[]) +} + +fn references_for_definition( + db: &RootDb, + sema: &Semantics<'_, RootDb>, + request_file_id: FileId, + config: &RenameConfig, + def: &Definition, +) -> RenameResult { + let refs_config = config.references_config(db, def, request_file_id)?; + Ok(ReferencesCtx::new(sema, def, refs_config).search()) +} + +fn rename_definition_with_refs( + db: &RootDb, + sema: &Semantics<'_, RootDb>, + def: &Definition, + new_name: &str, + rename_targets: Option<&UniqVec<(), DefinitionOrigin>>, + refs: &ReferenceSearchResult, + same_name_refs: &[SameNameConnectionRef], +) -> RenameResult { + let old_name = def + .origins() .into_iter() - .map(|file_toks| edits_from_refs(&sema, file_toks, &def, &old_name, new_name)) + .find_map(|origin| origin.name(db)) + .ok_or(RenameError::NoRefFound)?; + let mut source_changes = SourceChange::default(); + refs.iter() + .map(|(&file_id, toks)| { + edits_from_refs( + sema, + file_id, + toks, + def, + &old_name, + new_name, + rename_targets, + same_name_refs, + ) + }) .try_for_each(|(file_id, edit)| { source_changes .insert_text_edit(file_id, edit) @@ -129,31 +326,134 @@ pub(crate) fn rename( })?; for def in def.origins() { - let mut text_edit = TextEdit::builder(); - let Some(InFile { value: focus_range, file_id }) = def.name_range(db) else { continue; }; - text_edit.replace(focus_range, new_name.to_owned()); source_changes - .insert_text_edit(file_id.file_id(), text_edit.finish()) + .insert_text_edit( + file_id.file_id(), + TextEdit::replace(focus_range, new_name.to_owned()), + ) .map_err(|_| RenameError::OverlappingEdits)?; } Ok(source_changes) } -fn resolve_rename_definition( +fn recursive_rename_targets( + db: &RootDb, sema: &Semantics<'_, RootDb>, - hir_file_id: hir::file::HirFileId, + file_id: FileId, + config: &RenameConfig, + initial_targets: Vec, +) -> RenameResult> { + let mut targets = UniqVec::::default(); + for target in initial_targets { + targets.push(target.origins(), target); + } + let mut resolved_targets = Vec::new(); + let mut idx = 0; + while idx < targets.len() { + let current = targets.get(idx).clone(); + idx += 1; + + let refs = references_for_definition(db, sema, file_id, config, ¤t)?; + let same_name_refs = same_name_refs_collect(sema, &refs); + for conn_ref in &same_name_refs { + targets.push(conn_ref.conn.port.origins(), conn_ref.conn.port.clone()); + targets.push(conn_ref.conn.local.origins(), conn_ref.conn.local.clone()); + } + resolved_targets.push(RecursiveRenameTarget { def: current, refs, same_name_refs }); + } + + Ok(resolved_targets) +} + +fn same_name_refs_collect( + sema: &Semantics<'_, RootDb>, + refs_by_file: &ReferenceSearchResult, +) -> Vec { + let mut conn_refs = Vec::new(); + + for (&file_id, refs) in refs_by_file { + let parsed_file = sema.parse_file(file_id); + for token_ref in refs { + let range = token_ref.range(); + let Some(token) = token_ref.to_token(parsed_file.syntax_tree()) else { + continue; + }; + if let Some(conn) = check_same_name_conn(sema, file_id.into(), token) { + conn_refs.push(SameNameConnectionRef { file_id, range, conn }); + }; + } + } + + conn_refs +} + +fn check_same_name_conn( + sema: &Semantics<'_, RootDb>, + file_id: hir::file::HirFileId, token: SyntaxTokenWithParent<'_>, -) -> RenameResult { - match DefinitionClass::resolve(sema, hir_file_id, token).ok_or(RenameError::NoDefFound)? { - DefinitionClass::Definition(def) => Ok(def), - DefinitionClass::PortConnShorthand { local, .. } => Ok(local), - DefinitionClass::Ambiguous(_) => Err(RenameError::NoDefFound), +) -> Option { + let conn = + SyntaxAncestors::start_from(token.parent).find_map(ast::NamedPortConnection::cast)?; + let name_token = conn.name()?; + let name_range = name_token.text_range_in(conn.syntax())?; + let token_range = token.text_range()?; + let port_token = SyntaxTokenWithParent { parent: conn.syntax(), tok: name_token }; + let port_resolution = DefinitionClass::resolve(sema, file_id, port_token)?; + + let close_paren = match (conn.open_paren(), conn.close_paren()) { + (None, None) => { + if token_range != name_range { + return None; + } + + return match port_resolution { + DefinitionClass::PortConnShorthand { port, local } => { + Some(SameNameConnection { port, local, collapse_range: token_range }) + } + _ => None, + }; + } + (_, Some(close_paren)) => close_paren, + _ => return None, + }; + + let port = match port_resolution { + DefinitionClass::Definition(def) => def, + DefinitionClass::PortConnShorthand { port, .. } => port, + DefinitionClass::Ambiguous(_) => return None, + }; + let port_name = lower_ident(Some(name_token))?; + let expr = conn.expr()?.as_simple_property_expr()?.expr().as_simple_sequence_expr()?.expr(); + let actual_token = match expr { + Expression::Name(Name::IdentifierName(ident)) => ident.identifier()?, + Expression::Name(Name::IdentifierSelectName(ident)) + if ident.selectors().children().next().is_none() => + { + ident.identifier()? + } + _ => return None, + }; + if lower_ident(Some(actual_token))?.as_str() != port_name.as_str() { + return None; + } + let actual_token = SyntaxTokenWithParent { parent: expr.syntax(), tok: actual_token }; + + let actual_range = actual_token.text_range()?; + if token_range != name_range && token_range != actual_range { + return None; } + + let collapse_end = close_paren.text_range_in(conn.syntax())?.end(); + Some(SameNameConnection { + port, + local: Definition::from(sema.nameres_ident(file_id, actual_token)?), + collapse_range: TextRange::new(name_range.start(), collapse_end), + }) } fn origins_are_editable(db: &RootDb, def: &Definition, file_id: FileId) -> bool { @@ -165,25 +465,49 @@ fn origins_are_editable(db: &RootDb, def: &Definition, file_id: FileId) -> bool }) } +#[allow(clippy::too_many_arguments)] fn edits_from_refs( sema: &Semantics<'_, RootDb>, - (file_id, toks): (FileId, Vec), + file_id: FileId, + toks: &[ReferenceToken], def: &Definition, old_name: &str, new_name: &str, + rename_targets: Option<&UniqVec<(), DefinitionOrigin>>, + same_name_refs: &[SameNameConnectionRef], ) -> (FileId, TextEdit) { let mut text_edit = TextEdit::builder(); let text = sema.db.file_text(file_id); let hir_file_id = file_id.into(); let parsed_file = sema.parse_file(file_id); + let def_origins = def.origins(); + let same_name_refs: FxHashMap<_, _> = same_name_refs + .iter() + .filter(|it| it.file_id == file_id) + .map(|SameNameConnectionRef { range, conn, .. }| { + let SameNameConnection { port, local, collapse_range } = conn; + (*range, (port.origins(), local.origins(), *collapse_range)) + }) + .collect(); - for token_ref in toks.into_iter() { + for token_ref in toks { let range = token_ref.range(); let Some(token) = token_ref.to_token(parsed_file.syntax_tree()) else { continue; }; let SyntaxTokenWithParent { parent, tok } = token; + if let Some(rename_targets) = rename_targets + && let Some((ports, locals, collapse_range)) = same_name_refs.get(&range) + && ports.iter().any(|origin| rename_targets.contains(origin)) + && locals.iter().any(|origin| rename_targets.contains(origin)) + { + if def_origins.iter().any(|origin| ports.contains(origin)) { + text_edit.replace(*collapse_range, new_name.to_owned()); + } + continue; + } + let conn_data_range = |it: ast::NamedPortConnection| it.expr()?.syntax().text_range(); match_ast! { parent, diff --git a/crates/ide/src/verilog_2005.rs b/crates/ide/src/verilog_2005.rs index b8f2f240..0ad63130 100644 --- a/crates/ide/src/verilog_2005.rs +++ b/crates/ide/src/verilog_2005.rs @@ -429,6 +429,195 @@ fn best_effort_single_file_rename_rejects_cross_file_symbol() { assert!(matches!(err, RenameError::ProjectScopeRequired)); } +#[test] +fn expanded_rename_follows_same_name_shorthand_connection_chain() { + let text = r#" +module top(input a); + mid u_mid(.a); +endmodule + +module mid(input a); + leaf u_leaf(.a); +endmodule + +module leaf(input a); +endmodule +"#; + let (host, file_id) = setup_with_path(text, "/chain.sv"); + let analysis = host.make_analysis(); + let clean_text = normalize_fixture_text(text); + let offset = TextSize::from(clean_text.find("input a").expect("top port") as u32 + 6); + let position = FilePosition { file_id, offset }; + let config = RenameConfig::workspace(ScopeVisibility::Private); + + let info = analysis + .rename_expansion_info(position, config.clone()) + .unwrap() + .expect("recursive rename info expected"); + assert_eq!(info.additional_symbols, 2); + + let recursive = analysis + .expanded_rename(position, config.clone(), "renamed") + .unwrap() + .expect("recursive rename expected"); + let edit = recursive.text_edits.get(&file_id).expect("recursive rename should edit file"); + let mut renamed = clean_text.clone(); + edit.apply(&mut renamed); + assert!(renamed.contains("module top(input renamed);")); + assert!(renamed.contains("module mid(input renamed);")); + assert!(renamed.contains("module leaf(input renamed);")); + assert!(renamed.contains("mid u_mid(.renamed);")); + assert!(renamed.contains("leaf u_leaf(.renamed);")); + + let standard = + analysis.rename(position, config, "local_only").unwrap().expect("standard rename expected"); + let edit = standard.text_edits.get(&file_id).expect("standard rename should edit file"); + let mut standard_renamed = clean_text; + edit.apply(&mut standard_renamed); + assert!(standard_renamed.contains("module top(input local_only);")); + assert!(standard_renamed.contains("module mid(input a);")); + assert!(standard_renamed.contains("mid u_mid(.a(local_only));")); +} + +#[test] +fn expanded_rename_collapses_explicit_same_name_connection() { + let text = r#" +module top(input a); + child u_child(.a(a)); +endmodule + +module child(input a); +endmodule +"#; + let (host, file_id) = setup_with_path(text, "/explicit.sv"); + let analysis = host.make_analysis(); + let clean_text = normalize_fixture_text(text); + let offset = TextSize::from(clean_text.find("input a").expect("top port") as u32 + 6); + let position = FilePosition { file_id, offset }; + let config = RenameConfig::workspace(ScopeVisibility::Private); + + let recursive = analysis + .expanded_rename(position, config, "renamed") + .unwrap() + .expect("recursive rename expected"); + let edit = recursive.text_edits.get(&file_id).expect("recursive rename should edit file"); + let mut renamed = clean_text; + edit.apply(&mut renamed); + assert!(renamed.contains("module top(input renamed);")); + assert!(renamed.contains("module child(input renamed);")); + assert!(renamed.contains("child u_child(.renamed);")); +} + +#[test] +fn expanded_rename_skips_non_same_name_or_complex_connections() { + let cases = [ + ( + "different actual", + "module top(input b); child u(.a(b)); endmodule\nmodule child(input a); endmodule\n", + "input b", + ), + ( + "indexed actual", + "module top(input a); child u(.a(a[0])); endmodule\nmodule child(input a); endmodule\n", + "input a", + ), + ( + "member actual", + "module top; obj_t obj; child u(.a(obj.a)); endmodule\nmodule child(input a); endmodule\n", + "input a", + ), + ( + "ordered actual", + "module top(input a); child u(a); endmodule\nmodule child(input a); endmodule\n", + "input a", + ), + ( + "wildcard actual", + "module top(input a); child u(.*); endmodule\nmodule child(input a); endmodule\n", + "input a", + ), + ]; + + for (label, text, needle) in cases { + let (host, file_id) = setup_with_path(text, "/negative.sv"); + let analysis = host.make_analysis(); + let clean_text = normalize_fixture_text(text); + let offset = TextSize::from( + clean_text.find(needle).unwrap_or_else(|| panic!("missing needle for {label}")) as u32 + + needle.len() as u32 + - 1, + ); + let info = analysis + .rename_expansion_info( + FilePosition { file_id, offset }, + RenameConfig::workspace(ScopeVisibility::Private), + ) + .unwrap() + .expect("recursive rename info expected"); + assert_eq!(info.additional_symbols, 0, "{label}"); + } +} + +#[test] +fn rename_conflict_info_reports_same_scope_conflicts() { + let text = r#" +module top; + logic a; + logic b; + always_comb a = b; +endmodule +"#; + let (host, file_id) = setup_with_path(text, "/collision.sv"); + let analysis = host.make_analysis(); + let clean_text = normalize_fixture_text(text); + let offset = TextSize::from(clean_text.find("logic b").expect("b declaration") as u32 + 6); + let position = FilePosition { file_id, offset }; + let config = RenameConfig::workspace(ScopeVisibility::Private); + + let collision = analysis + .rename_conflict_info(position, config.clone(), "a", false) + .unwrap() + .expect("collision info expected"); + assert_eq!(collision.conflicts, 1); + + let no_collision = analysis + .rename_conflict_info(position, config, "b", false) + .unwrap() + .expect("collision info expected"); + assert_eq!(no_collision.conflicts, 0); +} + +#[test] +fn expanded_rename_conflict_info_checks_all_chain_targets() { + let text = r#" +module top(input a); + logic renamed; + child u_child(.a(a)); +endmodule + +module child(input a); +endmodule +"#; + let (host, file_id) = setup_with_path(text, "/recursive_collision.sv"); + let analysis = host.make_analysis(); + let clean_text = normalize_fixture_text(text); + let offset = TextSize::from(clean_text.find("input a").expect("top port") as u32 + 6); + let position = FilePosition { file_id, offset }; + let config = RenameConfig::workspace(ScopeVisibility::Private); + + let collision = analysis + .rename_conflict_info(position, config.clone(), "renamed", true) + .unwrap() + .expect("recursive collision info expected"); + assert_eq!(collision.conflicts, 1); + + let no_collision = analysis + .rename_conflict_info(position, config, "a", true) + .unwrap() + .expect("recursive collision info expected"); + assert_eq!(no_collision.conflicts, 0); +} + #[test] fn verilog_2005_navigation_rename_hover_and_completion_smoke() { let text = r#" diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs index 08a7cc42..a652fd8a 100644 --- a/crates/utils/src/lib.rs +++ b/crates/utils/src/lib.rs @@ -13,4 +13,5 @@ pub mod test_support; pub mod text_edit; pub mod thread; pub mod uimpl; +pub mod uniq_vec; pub mod utry; diff --git a/crates/utils/src/uniq_vec.rs b/crates/utils/src/uniq_vec.rs new file mode 100644 index 00000000..14c8d024 --- /dev/null +++ b/crates/utils/src/uniq_vec.rs @@ -0,0 +1,47 @@ +use std::hash::Hash; + +use rustc_hash::FxHashSet; + +pub struct UniqVec { + items: Vec, + seen: FxHashSet, +} + +impl Default for UniqVec { + fn default() -> Self { + Self { items: Vec::new(), seen: FxHashSet::default() } + } +} + +impl UniqVec { + pub fn push(&mut self, keys: impl IntoIterator, value: T) -> bool { + let keys = keys.into_iter().collect::>(); + if keys.iter().any(|key| self.seen.contains(key)) { + return false; + } + + self.seen.extend(keys); + self.items.push(value); + true + } + + pub fn contains(&self, key: &K) -> bool { + self.seen.contains(key) + } + + pub fn get(&self, idx: usize) -> &T { + &self.items[idx] + } + + pub fn len(&self) -> usize { + self.items.len() + } + + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + pub fn into_vec(self) -> Vec { + self.items + } +} diff --git a/editors/vscode/l10n/bundle.l10n.zh-cn.json b/editors/vscode/l10n/bundle.l10n.zh-cn.json index 57c847f1..4dffc1b2 100644 --- a/editors/vscode/l10n/bundle.l10n.zh-cn.json +++ b/editors/vscode/l10n/bundle.l10n.zh-cn.json @@ -75,6 +75,12 @@ "Qihe analysis is only available for Verilog files.": "Qihe 分析仅适用于 Verilog 文件。", "Vide language server is not running.": "Vide 语言服务器未运行。", "Failed to run Qihe analysis: {0}": "无法运行 Qihe 分析:{0}", + "Continue Rename": "继续重命名", + "Cancel": "取消", + "Renaming to \"{0}\" may collide with {1} existing symbol(s).": "重命名为 \"{0}\" 可能会与 {1} 个已有符号冲突。", + "Rename Connected Ports/Signals": "重命名连接的端口/信号", + "Only This Symbol": "仅重命名此符号", + "Rename {0} connected port/signal symbol(s) as well?": "是否同时重命名 {0} 个连接的端口/信号符号?", "Unable to update Vide diagnostic rules: {0}": "无法更新 Vide 诊断规则:{0}", "Restart": "重启", "Vide server configuration changed. Restart the language server to apply it.": "Vide 服务器配置已更改。请重启语言服务器以应用更改。", diff --git a/editors/vscode/src/browser/client.ts b/editors/vscode/src/browser/client.ts index feadfd31..e8d31c60 100644 --- a/editors/vscode/src/browser/client.ts +++ b/editors/vscode/src/browser/client.ts @@ -22,6 +22,10 @@ import { } from "./workspaceSnapshot"; const CLIENT_DISPOSED_MESSAGE = "Vide browser client has been disposed."; +const RENAME_EXPANSION_INFO_REQUEST = + "vide.server.renameExpansionInfo"; +const EXPANDED_RENAME_REQUEST = "vide.server.expandedRename"; +const RENAME_CONFLICT_INFO_REQUEST = "vide.server.renameConflictInfo"; export class VideBrowserClient { private readonly worker: Worker; @@ -150,6 +154,93 @@ export class VideBrowserClient { handleDiagnostics: (uri, diagnostics, next) => { next(uri, diagnostics); }, + provideRenameEdits: async (document, position, newName, token, next) => { + const languageClient = this.requireLanguageClient(); + const textDocumentPosition = { + textDocument: + languageClient.code2ProtocolConverter.asTextDocumentIdentifier(document), + position: languageClient.code2ProtocolConverter.asPosition(position), + }; + const standardRename = async () => { + if ( + !(await confirmRenameCollision( + languageClient, + textDocumentPosition, + newName, + false, + token, + )) + ) { + return emptyRenameEdit(); + } + return await next(document, position, newName, token); + }; + + let info: RenameExpansionInfo | undefined; + try { + info = await languageClient.sendRequest( + "workspace/executeCommand", + { + command: RENAME_EXPANSION_INFO_REQUEST, + arguments: [{ textDocumentPosition }], + }, + token, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.onLog(`Falling back to standard rename: ${message}`, "warn"); + } + + if (!info || info.additionalSymbols === 0) { + return await standardRename(); + } + + const recursiveAction = vscode.l10n.t( + "Rename Connected Ports/Signals", + ); + const localAction = vscode.l10n.t("Only This Symbol"); + const selected = await vscode.window.showInformationMessage( + vscode.l10n.t( + "Rename {0} connected port/signal symbol(s) as well?", + info.additionalSymbols, + ), + recursiveAction, + localAction, + ); + + if (selected === localAction) { + return await standardRename(); + } + + if (selected !== recursiveAction) { + return emptyRenameEdit(); + } + + if ( + !(await confirmRenameCollision( + languageClient, + textDocumentPosition, + newName, + true, + token, + )) + ) { + return emptyRenameEdit(); + } + + const edit = await languageClient.sendRequest( + "workspace/executeCommand", + { + command: EXPANDED_RENAME_REQUEST, + arguments: [{ textDocumentPosition, newName }], + }, + token, + ); + return await languageClient.protocol2CodeConverter.asWorkspaceEdit( + edit as never, + token, + ); + }, workspace: { configuration: () => [], }, @@ -207,6 +298,52 @@ export class VideBrowserClient { } } +type RenameExpansionInfo = { + additionalSymbols: number; +}; + +type RenameConflictInfo = { + conflicts: number; +}; + +function emptyRenameEdit(): vscode.WorkspaceEdit { + return new vscode.WorkspaceEdit(); +} + +async function confirmRenameCollision( + languageClient: VideLanguageClient, + textDocumentPosition: unknown, + newName: string, + recursive: boolean, + token: vscode.CancellationToken, +): Promise { + const info = await languageClient.sendRequest( + "workspace/executeCommand", + { + command: RENAME_CONFLICT_INFO_REQUEST, + arguments: [{ textDocumentPosition, newName, recursive }], + }, + token, + ); + + if (info.conflicts === 0) { + return true; + } + + const continueAction = vscode.l10n.t("Continue Rename"); + const cancelAction = vscode.l10n.t("Cancel"); + const selected = await vscode.window.showWarningMessage( + vscode.l10n.t( + 'Renaming to "{0}" may collide with {1} existing symbol(s).', + newName, + info.conflicts, + ), + continueAction, + cancelAction, + ); + return selected === continueAction; +} + class VideLanguageClient extends BaseLanguageClient { constructor( clientOptions: LanguageClientOptions, diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index 24774756..582e9abe 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -44,6 +44,9 @@ const showServerVersionCommand = 'vide.showServerVersion'; const showQiheOutputCommand = 'vide.showQiheOutput'; const runQiheAnalysisCommand = 'vide.runQiheAnalysis'; const runQiheAnalysisRequest = 'vide.server.runQiheAnalysis'; +const renameExpansionInfoRequest = 'vide.server.renameExpansionInfo'; +const expandedRenameRequest = 'vide.server.expandedRename'; +const renameConflictInfoRequest = 'vide.server.renameConflictInfo'; const qiheStatusNotification = 'vide/qiheStatus'; const qiheLogNotification = 'vide/qiheLog'; const qiheAnalysisIcon = '$(beaker)'; @@ -389,6 +392,137 @@ function includeDeclarationInReferences(document: vscode.TextDocument): boolean ); } +type RenameExpansionInfo = { + additionalSymbols: number; +}; + +type RenameConflictInfo = { + conflicts: number; +}; + +function emptyRenameEdit(): vscode.WorkspaceEdit { + return new vscode.WorkspaceEdit(); +} + +async function confirmRenameCollision( + textDocumentPosition: unknown, + newName: string, + recursive: boolean, + token: vscode.CancellationToken, +): Promise { + const languageClient = client; + if (!languageClient) { + return true; + } + + const info = await languageClient.sendRequest( + 'workspace/executeCommand', + { + command: renameConflictInfoRequest, + arguments: [{ textDocumentPosition, newName, recursive }], + }, + token, + ); + + if (info.conflicts === 0) { + return true; + } + + const continueAction = vscode.l10n.t('Continue Rename'); + const cancelAction = vscode.l10n.t('Cancel'); + const selected = await vscode.window.showWarningMessage( + vscode.l10n.t( + 'Renaming to "{0}" may collide with {1} existing symbol(s).', + newName, + info.conflicts, + ), + continueAction, + cancelAction, + ); + return selected === continueAction; +} + +async function provideExpandedRenameEdits( + document: vscode.TextDocument, + position: vscode.Position, + newName: string, + token: vscode.CancellationToken, + next: ( + document: vscode.TextDocument, + position: vscode.Position, + newName: string, + token: vscode.CancellationToken, + ) => vscode.ProviderResult, +): Promise { + const languageClient = client; + if (!languageClient) { + return await next(document, position, newName, token); + } + + const textDocumentPosition = { + textDocument: languageClient.code2ProtocolConverter.asTextDocumentIdentifier(document), + position: languageClient.code2ProtocolConverter.asPosition(position), + }; + const standardRename = async (): Promise => { + if (!(await confirmRenameCollision(textDocumentPosition, newName, false, token))) { + return emptyRenameEdit(); + } + return await next(document, position, newName, token); + }; + + let info: RenameExpansionInfo | undefined; + try { + info = await languageClient.sendRequest( + 'workspace/executeCommand', + { + command: renameExpansionInfoRequest, + arguments: [{ textDocumentPosition }], + }, + token, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log(`[WARN] Falling back to standard rename: ${message}`); + } + + if (!info || info.additionalSymbols === 0) { + return await standardRename(); + } + + const recursiveAction = vscode.l10n.t('Rename Connected Ports/Signals'); + const localAction = vscode.l10n.t('Only This Symbol'); + const selected = await vscode.window.showInformationMessage( + vscode.l10n.t( + 'Rename {0} connected port/signal symbol(s) as well?', + info.additionalSymbols, + ), + recursiveAction, + localAction, + ); + + if (selected === localAction) { + return await standardRename(); + } + + if (selected !== recursiveAction) { + return emptyRenameEdit(); + } + + if (!(await confirmRenameCollision(textDocumentPosition, newName, true, token))) { + return emptyRenameEdit(); + } + + const edit = await languageClient.sendRequest( + 'workspace/executeCommand', + { + command: expandedRenameRequest, + arguments: [{ textDocumentPosition, newName }], + }, + token, + ); + return await languageClient.protocol2CodeConverter.asWorkspaceEdit(edit as never, token); +} + function resolveWorkingDirectory( context: vscode.ExtensionContext, configuredCwd: string | undefined, @@ -662,6 +796,7 @@ async function createClient(context: vscode.ExtensionContext): Promise anyhow::Result> { - let args = params.arguments.first().cloned().ok_or_else(|| { - anyhow::format_err!("{}", state.config.i18n.text(keys::EXECUTE_COMMAND_MISSING_ARGUMENTS)) - })?; - let params = serde_json::from_value::(args)?; + let params = extract_execute_arg::(state, ¶ms)?; state.spawn_qihe_analysis(params); Ok(None) } @@ -249,6 +252,64 @@ fn handle_reload_workspace_command( Ok(None) } +fn handle_rename_expansion_info_command( + state: &mut crate::global_state::GlobalState, + params: lsp_types::ExecuteCommandParams, +) -> anyhow::Result> { + let params = extract_execute_arg::(state, ¶ms)?; + let snap = state.make_snapshot(); + let position = from_proto::file_position(&snap, params.text_document_position)?; + let config = snap.rename_config(position.file_id); + let info = snap + .analysis + .rename_expansion_info(position, config)? + .map_err(|err| to_proto::rename_error(snap.config.i18n, err))?; + let result = RenameExpansionInfoResult { additional_symbols: info.additional_symbols }; + Ok(Some(serde_json::to_value(result)?)) +} + +fn handle_expanded_rename_command( + state: &mut crate::global_state::GlobalState, + params: lsp_types::ExecuteCommandParams, +) -> anyhow::Result> { + let params = extract_execute_arg::(state, ¶ms)?; + let snap = state.make_snapshot(); + let position = from_proto::file_position(&snap, params.text_document_position)?; + let config = snap.rename_config(position.file_id); + let change = snap + .analysis + .expanded_rename(position, config, ¶ms.new_name)? + .map_err(|err| to_proto::rename_error(snap.config.i18n, err))?; + let workspace_edit = to_proto::workspace_edit(&snap, change)?; + Ok(Some(serde_json::to_value(workspace_edit)?)) +} + +fn handle_rename_conflict_info_command( + state: &mut crate::global_state::GlobalState, + params: lsp_types::ExecuteCommandParams, +) -> anyhow::Result> { + let params = extract_execute_arg::(state, ¶ms)?; + let snap = state.make_snapshot(); + let position = from_proto::file_position(&snap, params.text_document_position)?; + let config = snap.rename_config(position.file_id); + let info = snap + .analysis + .rename_conflict_info(position, config, ¶ms.new_name, params.recursive)? + .map_err(|err| to_proto::rename_error(snap.config.i18n, err))?; + let result = RenameConflictInfoResult { conflicts: info.conflicts }; + Ok(Some(serde_json::to_value(result)?)) +} + +fn extract_execute_arg( + state: &crate::global_state::GlobalState, + params: &lsp_types::ExecuteCommandParams, +) -> anyhow::Result { + let args = params.arguments.first().cloned().ok_or_else(|| { + anyhow::format_err!("{}", state.config.i18n.text(keys::EXECUTE_COMMAND_MISSING_ARGUMENTS)) + })?; + Ok(serde_json::from_value(args)?) +} + pub(crate) fn handle_execute_command( state: &mut crate::global_state::GlobalState, params: lsp_types::ExecuteCommandParams, @@ -256,6 +317,9 @@ pub(crate) fn handle_execute_command( match params.command.as_str() { RUN_QIHE_ANALYSIS_COMMAND => handle_qihe_analysis_command(state, params), RELOAD_WORKSPACE_COMMAND => handle_reload_workspace_command(state), + RENAME_EXPANSION_INFO_COMMAND => handle_rename_expansion_info_command(state, params), + EXPANDED_RENAME_COMMAND => handle_expanded_rename_command(state, params), + RENAME_CONFLICT_INFO_COMMAND => handle_rename_conflict_info_command(state, params), _ => anyhow::bail!( "{}", state diff --git a/src/lsp_ext/ext.rs b/src/lsp_ext/ext.rs index 167eeaf4..d4ed787f 100644 --- a/src/lsp_ext/ext.rs +++ b/src/lsp_ext/ext.rs @@ -142,6 +142,9 @@ pub enum CodeActionResolveError { pub const RUN_QIHE_ANALYSIS_COMMAND: &str = "vide.server.runQiheAnalysis"; pub const RELOAD_WORKSPACE_COMMAND: &str = "vide.server.reloadWorkspace"; +pub const RENAME_EXPANSION_INFO_COMMAND: &str = "vide.server.renameExpansionInfo"; +pub const EXPANDED_RENAME_COMMAND: &str = "vide.server.expandedRename"; +pub const RENAME_CONFLICT_INFO_COMMAND: &str = "vide.server.renameConflictInfo"; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -151,6 +154,40 @@ pub struct RunQiheAnalysisParams { pub cwd: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RenameExpansionInfoParams { + pub text_document_position: lsp_types::TextDocumentPositionParams, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RenameExpansionInfoResult { + pub additional_symbols: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExpandedRenameParams { + pub text_document_position: lsp_types::TextDocumentPositionParams, + pub new_name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RenameConflictInfoParams { + pub text_document_position: lsp_types::TextDocumentPositionParams, + pub new_name: String, + #[serde(default)] + pub recursive: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RenameConflictInfoResult { + pub conflicts: usize, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct QiheStatusParams { diff --git a/src/tests.rs b/src/tests.rs index 30640072..3dba0954 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -23,8 +23,8 @@ use lsp_types::{ }, request::{ CodeActionRequest, CodeLensRequest, CodeLensResolve, DocumentDiagnosticRequest, - DocumentSymbolRequest, FoldingRangeRequest, GotoDefinition, HoverRequest, References, - Request as _, SemanticTokensFullRequest, Shutdown, WorkspaceConfiguration, + DocumentSymbolRequest, ExecuteCommand, FoldingRangeRequest, GotoDefinition, HoverRequest, + References, Request as _, SemanticTokensFullRequest, Shutdown, WorkspaceConfiguration, WorkspaceDiagnosticRequest, }, }; @@ -39,7 +39,14 @@ use crate::{ }, global_state::main_loop, i18n::{I18n, Locale}, - lsp_ext::{ext::ProjectStatusNotification, to_proto}, + lsp_ext::{ + ext::{ + EXPANDED_RENAME_COMMAND, ExpandedRenameParams, ProjectStatusNotification, + RENAME_CONFLICT_INFO_COMMAND, RENAME_EXPANSION_INFO_COMMAND, RenameConflictInfoParams, + RenameConflictInfoResult, RenameExpansionInfoParams, RenameExpansionInfoResult, + }, + to_proto, + }, }; type TempDir = TestDir; @@ -497,6 +504,29 @@ fn request_rename( .unwrap_or_else(|err| panic!("failed to decode rename response: {err}")) } +fn request_execute_command_response( + client: &Connection, + command: &str, + arguments: Vec, + request_id: i32, +) -> lsp_server::Response { + let request_id = lsp_server::RequestId::from(request_id); + client + .sender + .send(Message::Request(Request::new( + request_id.clone(), + ExecuteCommand::METHOD.to_string(), + lsp_types::ExecuteCommandParams { + command: command.to_owned(), + arguments, + work_done_progress_params: WorkDoneProgressParams::default(), + }, + ))) + .unwrap(); + + recv_raw_response(client, request_id, "executeCommand") +} + fn request_workspace_diagnostic_report( client: &Connection, request_id: i32, @@ -1703,6 +1733,152 @@ fn configured_workspace_rename_updates_cross_file_symbol() { shutdown_test_server(&client, server_thread); } +#[test] +fn configured_workspace_expanded_rename_command_updates_chain() { + let child_text = "module child(input a);\nendmodule\n"; + let top_text = "module top(input a);\n child u(.a(a));\nendmodule\n"; + let (_temp_dir, client, server_thread, uris) = setup_configured_multi_file_diagnostics_test( + ClientCapabilities::default(), + UserConfig::default(), + &[("child.sv", child_text), ("top.sv", top_text)], + ); + let child_uri = uris[0].clone(); + let top_uri = uris[1].clone(); + let _ = request_document_diagnostics(&client, top_uri.clone(), 1); + + let text_document_position = TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: top_uri.clone() }, + position: position_of(top_text, "a);\n child"), + }; + let info_response = request_execute_command_response( + &client, + RENAME_EXPANSION_INFO_COMMAND, + vec![ + serde_json::to_value(RenameExpansionInfoParams { + text_document_position: text_document_position.clone(), + }) + .unwrap(), + ], + 2, + ); + assert!(info_response.error.is_none(), "rename info returned error: {:?}", info_response.error); + let info: RenameExpansionInfoResult = + serde_json::from_value(info_response.result.unwrap()).unwrap(); + assert_eq!(info.additional_symbols, 1); + + let rename_response = request_execute_command_response( + &client, + EXPANDED_RENAME_COMMAND, + vec![ + serde_json::to_value(ExpandedRenameParams { + text_document_position, + new_name: "renamed".to_owned(), + }) + .unwrap(), + ], + 3, + ); + assert!( + rename_response.error.is_none(), + "recursive rename returned error: {:?}", + rename_response.error + ); + let edit: lsp_types::WorkspaceEdit = + serde_json::from_value(rename_response.result.unwrap()).unwrap(); + let Some(lsp_types::DocumentChanges::Edits(document_edits)) = edit.document_changes else { + panic!("recursive rename should use document edits: {edit:?}"); + }; + assert!( + document_edits.iter().any(|edit| edit.text_document.uri == top_uri), + "recursive rename should edit top file: {document_edits:?}" + ); + assert!( + document_edits.iter().any(|edit| edit.text_document.uri == child_uri), + "recursive rename should edit child file: {document_edits:?}" + ); + assert!( + document_edits.iter().flat_map(|edit| edit.edits.iter()).any(|edit| { + matches!(edit, lsp_types::OneOf::Left(edit) if edit.new_text == "renamed") + }), + "recursive rename should contain rename edits: {document_edits:?}" + ); + + shutdown_test_server(&client, server_thread); +} + +#[test] +fn configured_workspace_rename_conflict_info_command_reports_conflicts() { + let text = "module top;\n logic a;\n logic b;\n assign a = b;\nendmodule\n"; + let (_temp_dir, client, server_thread, uris) = setup_configured_multi_file_diagnostics_test( + ClientCapabilities::default(), + UserConfig::default(), + &[("top.sv", text)], + ); + let uri = uris[0].clone(); + let _ = request_document_diagnostics(&client, uri.clone(), 1); + + let response = request_execute_command_response( + &client, + RENAME_CONFLICT_INFO_COMMAND, + vec![ + serde_json::to_value(RenameConflictInfoParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri }, + position: position_of(text, "b;"), + }, + new_name: "a".to_owned(), + recursive: false, + }) + .unwrap(), + ], + 2, + ); + assert!(response.error.is_none(), "rename collision info returned error: {:?}", response.error); + let info: RenameConflictInfoResult = serde_json::from_value(response.result.unwrap()).unwrap(); + assert_eq!(info.conflicts, 1); + + shutdown_test_server(&client, server_thread); +} + +#[test] +fn unconfigured_workspace_expanded_rename_command_rejects_cross_file_chain() { + let child_text = "module child(input a);\nendmodule\n"; + let top_text = "module top(input a);\n child u(.a(a));\nendmodule\n"; + let (_temp_dir, client, server_thread, uris) = setup_multi_file_diagnostics_test_inner( + ClientCapabilities::default(), + UserConfig::default(), + &[("child.sv", child_text), ("top.sv", top_text)], + false, + ); + let top_uri = uris[1].clone(); + let _ = request_document_diagnostics(&client, top_uri.clone(), 1); + + let response = request_execute_command_response( + &client, + EXPANDED_RENAME_COMMAND, + vec![ + serde_json::to_value(ExpandedRenameParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: top_uri }, + position: position_of(top_text, "a);\n child"), + }, + new_name: "renamed".to_owned(), + }) + .unwrap(), + ], + 2, + ); + let error = response.error.expect("recursive cross-file rename should be rejected"); + assert!( + error + .message + .contains("This rename can affect other files. Add vide.toml to make the editable project scope explicit."), + "unexpected recursive rename error: {error:?}" + ); + + shutdown_test_server(&client, server_thread); +} + #[test] fn unconfigured_workspace_diagnostics_skip_unopened_indexed_files() { let pull_caps = ClientCapabilities {