use std::collections::HashMap;
use cpp_code_analysis::{Known, Session};
use lsp_types::{
ClientCapabilities, OneOf, PrepareRenameResponse, RenameOptions, RenameParams,
ServerCapabilities, TextDocumentPositionParams, TextEdit, WorkspaceEdit,
};
use tokio_util::sync::CancellationToken;
use super::RegisterCapabilities;
use super::references::macro_at;
use crate::context::{RequestOutcome, ServerContextSnapshot, snapshot_query};
use crate::util::{path_to_uri, position_in_file, uri_to_file_path};
pub async fn on_prepare_rename(
context: ServerContextSnapshot,
params: TextDocumentPositionParams,
cancel_token: CancellationToken,
) -> RequestOutcome<PrepareRenameResponse> {
let uri = params.text_document.uri;
let position = params.position;
if let Some(path) = uri_to_file_path(&uri) {
context.analysis().prepare(&path).await;
context.analysis().catch_up(&path).await;
}
let still_reading = context
.analysis()
.with_snapshot(|session| session.pending())
.unwrap_or(0);
if still_reading > 0 {
context.client().show_message(lsp_types::ShowMessageParams {
typ: lsp_types::MessageType::INFO,
message: format!(
"cpp_ls is still reading the project ({still_reading} file(s) left), so a rename would miss uses. \
The editor offers it again when the index has caught up."
),
});
}
snapshot_query(context.analysis(), cancel_token, move |session| {
let path = uri_to_file_path(&uri)?;
let view = session.view_of_the_file(&path)?;
let offset = crate::util::offset_at_position(&view, position)?;
let (name, range) = match macro_at(session, &view, offset) {
Some(found) => found,
None => symbol_at(session, &view, offset)?,
};
let held = session.files().held(&view.path)?;
Some(PrepareRenameResponse::RangeWithPlaceholder {
range: lsp_types::Range::new(
position_in_file(held, range.start_offset)?,
position_in_file(held, range.end_offset())?,
),
placeholder: name,
})
})
.await
}
pub async fn on_rename(
context: ServerContextSnapshot,
params: RenameParams,
cancel_token: CancellationToken,
) -> RequestOutcome<WorkspaceEdit> {
let uri = params.text_document_position.text_document.uri;
let position = params.text_document_position.position;
let new_name = params.new_name;
if let Some(path) = uri_to_file_path(&uri) {
context.analysis().prepare(&path).await;
}
snapshot_query(context.analysis(), cancel_token, move |session| {
let path = uri_to_file_path(&uri)?;
let view = session.view_of_the_file(&path)?;
let offset = crate::util::offset_at_position(&view, position)?;
workspace_edit_of_renaming_a_macro(session, &view, offset, &new_name)
.or_else(|| workspace_edit_of_renaming_a_symbol(session, &view, offset, &new_name))
})
.await
}
pub fn symbol_at<F: cpp_code_analysis::FileProvider + Clone>(
session: &Session<F>,
view: &cpp_code_analysis::FileView,
offset: cpp_code_analysis::file::coordinates::ReadingOffset,
) -> Option<(String, cpp_parser::SourceRange)> {
let Known::Yes(_) = session.definitions(view, offset) else {
return None;
};
let (name, range) =
cpp_code_analysis::sema::resolve::name_at_including_directives(&view.root, offset.get())?;
Some((name, range))
}
pub fn workspace_edit_of_renaming_a_symbol<F: cpp_code_analysis::FileProvider + Clone>(
session: &Session<F>,
view: &cpp_code_analysis::FileView,
offset: cpp_code_analysis::file::coordinates::ReadingOffset,
new_name: &str,
) -> Option<WorkspaceEdit> {
let (name, name_range) = symbol_at(session, view, offset)?;
if !is_one_identifier(new_name) {
log::debug!("refusing a rename to {new_name:?}: not a single identifier");
return None;
}
if session.pending() > 0 {
log::debug!(
"refusing to rename `{name}`: {} file(s) are queued, so the search would be partial",
session.pending()
);
return None;
}
let defined = match session.definitions(view, offset) {
Known::Yes(defined) => defined,
_ => return None,
};
if defined.found.iter().any(|definition| definition.fact.local) {
let locals =
cpp_code_analysis::sema::declarations::local_references_of(&view.scopes, &view.root);
let (_, at_the_cursor) = cpp_code_analysis::sema::resolve::name_at_including_directives(
&view.root,
offset.get(),
)?;
let uses: Vec<u32> = locals
.iter()
.find(|entry| {
entry.declared_at as usize == at_the_cursor.start_offset
|| entry
.uses
.iter()
.any(|at| *at as usize == at_the_cursor.start_offset)
})
.map(|entry| entry.uses.clone())
.unwrap_or_default();
let mut ranges: Vec<u32> = vec![at_the_cursor.start_offset as u32];
ranges.extend(uses);
let mut changes: HashMap<lsp_types::Uri, Vec<TextEdit>> = HashMap::new();
let held = session.files().held(&view.path)?;
let uri = path_to_uri(&view.path)?;
for at in ranges {
let Some((_, range)) = cpp_code_analysis::sema::resolve::name_at_including_directives(
&view.root,
at as usize,
) else {
continue;
};
let (Some(start), Some(end)) = (
position_in_file(held, range.start_offset),
position_in_file(held, range.end_offset()),
) else {
return None;
};
changes.entry(uri.clone()).or_default().push(TextEdit {
range: lsp_types::Range::new(start, end),
new_text: new_name.to_string(),
});
}
return Some(WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
});
}
let found = crate::handlers::references::name_level_offsets(session, view, offset, true);
if found.is_empty() {
log::debug!("refusing to rename `{name}`: no declaration was found for it");
return None;
}
if let Some(outside) = found
.iter()
.map(|(file, _)| file)
.find(|file| !crate::util::is_inside(file, session.root()))
{
log::warn!(
"refusing to rename `{name}`: {} is outside the project",
outside.display()
);
return None;
}
let mut changes: HashMap<lsp_types::Uri, Vec<TextEdit>> = HashMap::new();
for (file, range) in found {
let Some(held) = session.files().held(&file) else {
log::warn!(
"refusing to rename `{name}`: {} is not held, so its edits cannot be placed",
file.display()
);
return None;
};
let (Some(uri), Some(start), Some(end)) = (
path_to_uri(&file),
position_in_file(held, range.start_offset),
position_in_file(held, range.end_offset()),
) else {
return None;
};
changes.entry(uri).or_default().push(TextEdit {
range: lsp_types::Range::new(start, end),
new_text: new_name.to_string(),
});
}
let _ = name_range;
Some(WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
})
}
pub fn workspace_edit_of_renaming_a_macro<F: cpp_code_analysis::FileProvider + Clone>(
session: &Session<F>,
view: &cpp_code_analysis::FileView,
offset: cpp_code_analysis::file::coordinates::ReadingOffset,
new_name: &str,
) -> Option<WorkspaceEdit> {
macro_at(session, view, offset)?;
if !is_one_identifier(new_name) {
log::debug!("refusing a rename to {new_name:?}: not a single identifier");
return None;
}
if session.pending() > 0 {
log::debug!(
"refusing to rename: {} file(s) are queued, so the search would be partial",
session.pending()
);
return None;
}
let Known::Yes(found) = session.macro_references(view, offset) else {
return None;
};
if found.not_looked_at > 0 || !found.unreadable.is_empty() {
log::warn!(
"refusing to rename `{}`: {} candidate file(s) were not looked at and {} could not be read",
found.name,
found.not_looked_at,
found.unreadable.len()
);
return None;
}
if let Some(outside) = found
.files
.iter()
.map(|file| &file.file)
.find(|file| !crate::util::is_inside(file, session.root()))
{
log::warn!(
"refusing to rename `{}`: {} is outside the project",
found.name,
outside.display()
);
return None;
}
let mut changes: HashMap<lsp_types::Uri, Vec<TextEdit>> = HashMap::new();
for edit in found.rename(new_name) {
let Some(held) = session.files().held(&edit.file) else {
log::warn!(
"refusing to rename `{}`: {} is not held, so its edits cannot be placed",
found.name,
edit.file.display()
);
return None;
};
let (Some(uri), Some(start), Some(end)) = (
path_to_uri(&edit.file),
position_in_file(held, edit.range.start_offset),
position_in_file(held, edit.range.end_offset()),
) else {
return None;
};
changes.entry(uri).or_default().push(TextEdit {
range: lsp_types::Range::new(start, end),
new_text: edit.replacement.clone(),
});
}
if found.uncertain() > 0 {
log::info!(
"renaming `{}`: {} occurrence(s) reached through a conditional include or an `#if` were left alone",
found.name,
found.uncertain()
);
}
Some(WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
})
}
fn is_one_identifier(name: &str) -> bool {
let (tokens, _) = cpp_parser::lex(name, &cpp_parser::LexerConfig::default());
let mut significant = tokens.iter().filter(|token| {
!cpp_parser::is_trivia(token.kind) && token.kind != cpp_parser::CppTokenKind::Eof
});
let first = significant.next();
matches!(first, Some(token) if token.kind == cpp_parser::CppTokenKind::Identifier)
&& significant.next().is_none()
}
pub struct RenameCapabilities;
impl RegisterCapabilities for RenameCapabilities {
fn register_capabilities(server_capabilities: &mut ServerCapabilities, _: &ClientCapabilities) {
server_capabilities.rename_provider = Some(OneOf::Right(RenameOptions {
prepare_provider: Some(true),
work_done_progress_options: Default::default(),
}));
}
}
#[cfg(test)]
mod tests {
use super::{is_one_identifier, workspace_edit_of_renaming_a_macro};
use cpp_code_analysis::{
CompilerConfig, MemoryFiles, OpenDocuments, Session, SessionFiles, WatchFilter,
};
use std::path::PathBuf;
const LIMITS_H: &str = "#define MAX_ITEMS 64\n";
const ITEMS_CPP: &str = "#include \"limits.h\"\nint items[MAX_ITEMS];\n";
#[test]
fn a_new_name_is_one_identifier_and_nothing_else() {
for name in [
"Widget",
"_STD_BEGIN",
"MAX_ENTRIES",
"x",
"Widget ",
" Widget",
] {
assert!(is_one_identifier(name), "{name:?} is a name");
}
for name in [
"",
" ",
"two words",
"MAX ENTRIES",
"1abc",
"a+b",
"\"quoted\"",
"std::string",
"a;",
] {
assert!(!is_one_identifier(name), "{name:?} is not a name");
}
}
#[test]
fn a_rename_is_refused_while_the_index_has_work() {
let files = MemoryFiles::new()
.with_file("/p/limits.h", LIMITS_H)
.with_file("/p/items.cpp", ITEMS_CPP);
let providers = SessionFiles::new(OpenDocuments::new(), files);
let mut session = Session::with_config(
"/p",
providers,
WatchFilter::new("/p"),
CompilerConfig::default(),
);
session.add_project_files([PathBuf::from("/p/limits.h"), PathBuf::from("/p/items.cpp")]);
session.load("/p/items.cpp");
let held = session
.files()
.held("/p/items.cpp")
.expect("the file is held");
let view =
cpp_code_analysis::FileView::parse_with(held, &cpp_parser::MacroEnvironment::default());
let on_the_use = ITEMS_CPP
.find("MAX_ITEMS")
.expect("the fixture uses the macro");
assert!(session.pending() > 0, "the fixture is the refused state");
assert!(
workspace_edit_of_renaming_a_macro(
&session,
&view,
cpp_code_analysis::file::coordinates::ReadingOffset::new(on_the_use),
"MAX_ENTRIES"
)
.is_none(),
"a rename now would edit the uses the index knows and skip the ones it has not read"
);
session.index_everything();
let held = session
.files()
.held("/p/items.cpp")
.expect("the file is held");
let view =
cpp_code_analysis::FileView::parse_with(held, &cpp_parser::MacroEnvironment::default());
let edit = workspace_edit_of_renaming_a_macro(
&session,
&view,
cpp_code_analysis::file::coordinates::ReadingOffset::new(on_the_use),
"MAX_ENTRIES",
)
.expect("the index is complete now");
let changes = edit.changes.expect("a change map");
assert_eq!(changes.len(), 2, "the definer and the user: {changes:?}");
assert!(
changes
.values()
.all(|edits| edits.iter().all(|edit| edit.new_text == "MAX_ENTRIES")),
"every edit writes the new name: {changes:?}"
);
}
}
#[cfg(test)]
mod inside {
use std::path::Path;
use crate::util::is_inside;
#[test]
fn the_project_boundary_is_compared_the_way_paths_are() {
let root = Path::new("/p");
assert!(is_inside(Path::new("/p/a.cpp"), root));
assert!(is_inside(Path::new("/p/src/deep/a.cpp"), root));
assert!(!is_inside(Path::new("/project2/a.cpp"), root));
assert!(!is_inside(Path::new("/a.cpp"), root));
assert!(
!is_inside(Path::new("/p"), Path::new("/p/src")),
"the root is not inside a child"
);
assert!(is_inside(Path::new(r"\p\a.cpp"), root));
assert!(is_inside(Path::new("/p/a.cpp"), Path::new(r"\p")));
if cfg!(windows) {
assert!(is_inside(Path::new("/P/A.CPP"), root));
}
}
}
#[cfg(test)]
mod a_symbol {
use std::path::PathBuf;
use cpp_code_analysis::{
CompilerConfig, MemoryFiles, OpenDocuments, Session, SessionFiles, WatchFilter,
};
use super::{symbol_at, workspace_edit_of_renaming_a_symbol};
const BASE_H: &str = "\
struct First {
int _value;
void update();
};
struct Second {
int _value;
void update();
};
";
const USE_CPP: &str = "\
#include \"base.h\"
void First::update() { _value = 1; }
void Second::update() { _value = 2; }
";
fn session() -> (Session<MemoryFiles>, PathBuf) {
let files = MemoryFiles::new()
.with_file("/p/base.h", BASE_H)
.with_file("/p/use.cpp", USE_CPP);
let providers = SessionFiles::new(OpenDocuments::new(), files);
let mut session = Session::with_config(
"/p",
providers,
WatchFilter::new("/p"),
CompilerConfig::default(),
);
session.add_project_files([PathBuf::from("/p/base.h"), PathBuf::from("/p/use.cpp")]);
session.index_everything();
(session, PathBuf::from("/p/use.cpp"))
}
#[test]
fn is_renamed_in_its_own_class_and_the_declaration_follows() {
let (session, path) = session();
let held = session.files().held(&path).expect("the file is held");
let view =
cpp_code_analysis::FileView::parse_with(held, &cpp_parser::MacroEnvironment::default());
let at = USE_CPP
.find("_value = 1")
.expect("the fixture writes the member in First::update");
let (name, range) = symbol_at(
&session,
&view,
cpp_code_analysis::file::coordinates::ReadingOffset::new(at),
)
.expect("a member is a symbol");
assert_eq!(name, "_value");
assert_eq!(&USE_CPP[range.start_offset..range.end_offset()], "_value");
let edit = workspace_edit_of_renaming_a_symbol(
&session,
&view,
cpp_code_analysis::file::coordinates::ReadingOffset::new(at),
"_renamed",
)
.expect("an indexed project can rename a member");
let changes = edit.changes.expect("a change map");
let total: usize = changes.values().map(|edits| edits.len()).sum();
println!(
"edits: {:?}",
changes
.iter()
.map(|(uri, edits)| (uri.path().to_string(), edits.len()))
.collect::<Vec<_>>()
);
assert_eq!(
changes.len(),
2,
"the header's declaration and the source's use: {changes:?}"
);
assert_eq!(
total, 2,
"and not `Second`'s declaration or its use: {changes:?}"
);
assert!(
changes
.values()
.flatten()
.all(|edit| edit.new_text == "_renamed"),
"every edit writes the new name"
);
}
}