diff --git a/README.md b/README.md index 6ac74a9..f7de65b 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,14 @@ Super fast incremental analysis! Scans `all-packages.nix` in less than 0.1s and - [x] Warnings of unused bindings, `with` and `rec`. - [ ] Client pulled diagnostics. - [x] Expand selection. `textDocument/selectionRange` +- [x] Renaming. `textDocument/renamme`, `textDocument/prepareRename` + - [x] Identifiers in parameters and bindings, from `let`, rec and non-rec attrsets. + - [x] Static string literal bindings. + - [x] Merged path-value binding names. + - [ ] Names introduced by `inherit`. + - [ ] Names used by `inherit`. + - [ ] Conflict detection. + - [ ] Rename to string literals. - [ ] Cross-file analysis. - [ ] Multi-threaded. diff --git a/crates/ide/src/ide/mod.rs b/crates/ide/src/ide/mod.rs index 4456c22..a2f1e97 100644 --- a/crates/ide/src/ide/mod.rs +++ b/crates/ide/src/ide/mod.rs @@ -3,12 +3,14 @@ mod diagnostics; mod expand_selection; mod goto_definition; mod references; +mod rename; use crate::base::SourceDatabaseStorage; use crate::def::DefDatabaseStorage; -use crate::{Change, Diagnostic, FileId, FilePos, FileRange}; +use crate::{Change, Diagnostic, FileId, FilePos, FileRange, WorkspaceEdit}; use rowan::TextRange; use salsa::{Database, Durability, ParallelDatabase}; +use smol_str::SmolStr; use std::fmt; pub use completion::{CompletionItem, CompletionItemKind}; @@ -21,6 +23,8 @@ pub struct NavigationTarget { } pub use salsa::Cancelled; + +use self::rename::RenameResult; pub type Cancellable = Result; #[salsa::database(SourceDatabaseStorage, DefDatabaseStorage)] @@ -100,6 +104,18 @@ impl Analysis { self.with_db(|db| references::references(db, pos)) } + pub fn prepare_rename(&self, fpos: FilePos) -> Cancellable> { + self.with_db(|db| rename::prepare_rename(db, fpos)) + } + + pub fn rename( + &self, + fpos: FilePos, + new_name: &str, + ) -> Cancellable> { + self.with_db(|db| rename::rename(db, fpos, new_name)) + } + pub fn expand_selection(&self, frange: FileRange) -> Cancellable>> { self.with_db(|db| expand_selection::expand_selection(db, frange)) } diff --git a/crates/ide/src/ide/rename.rs b/crates/ide/src/ide/rename.rs new file mode 100644 index 0000000..e1b4089 --- /dev/null +++ b/crates/ide/src/ide/rename.rs @@ -0,0 +1,346 @@ +use crate::def::{AstPtr, NameId, ResolveResult}; +use crate::{DefDatabase, FilePos, TextEdit, WorkspaceEdit}; +use rowan::ast::AstNode; +use smol_str::SmolStr; +use syntax::{ast, best_token_at_offset, match_ast, SyntaxKind, TextRange}; + +pub type RenameResult = Result; + +pub(crate) fn prepare_rename( + db: &dyn DefDatabase, + fpos: FilePos, +) -> RenameResult<(TextRange, SmolStr)> { + let (range, name) = find_name(db, fpos).ok_or_else(|| "No references found".to_owned())?; + let module = db.module(fpos.file_id); + let text = module[name].text.clone(); + Ok((range, text)) +} + +pub(crate) fn rename( + db: &dyn DefDatabase, + fpos: FilePos, + new_name: &str, +) -> RenameResult { + let (_, name) = find_name(db, fpos).ok_or_else(|| "No references found".to_owned())?; + if !is_valid_ident(new_name) { + return Err("Invalid new identifier".into()); + } + + let file_id = fpos.file_id; + let parse = db.parse(file_id); + let source_map = db.source_map(file_id); + + let mut edits = Vec::new(); + let new_name = SmolStr::from(new_name); + + // Rename definitions. + for ptr in source_map.name_nodes(name) { + let node = ptr.to_node(&parse.syntax_node()); + if matches!(node.parent(), Some(p) if p.kind() == SyntaxKind::INHERIT) { + return Err("Renaming `inherit`ed variables is not supported yet".into()); + } + edits.push(TextEdit { + delete: node.text_range(), + insert: new_name.clone(), + }); + } + + // Rename usages. + let name_refs = db.name_reference(file_id); + let refs = name_refs.name_references(name).unwrap_or_default(); + for &expr in refs { + let ptr = source_map + .expr_node(expr) + .expect("Must be a valid Expr::Reference"); + let node = ptr.to_node(&parse.syntax_node()); + if matches!(node.parent(), Some(p) if p.kind() == SyntaxKind::INHERIT) { + return Err("Renaming variables being `inherit`ed is not supported yet".into()); + } + edits.push(TextEdit { + delete: ptr.text_range(), + insert: new_name.clone(), + }); + } + + edits.sort_by_key(|edit| edit.delete.start()); + assert!( + edits + .windows(2) + .all(|w| w[0].delete.end() <= w[1].delete.start()), + "Should not overlap" + ); + + Ok(WorkspaceEdit { + content_edits: [(file_id, edits)].into_iter().collect(), + }) +} + +fn find_name( + db: &dyn DefDatabase, + FilePos { file_id, pos }: FilePos, +) -> Option<(TextRange, NameId)> { + let parse = db.parse(file_id); + let tok = best_token_at_offset(&parse.syntax_node(), pos)?; + let mut node = tok.parent_ancestors().find_map(|node| { + match_ast! { + match node { + ast::Ref(n) => Some(n.syntax().clone()), + ast::Name(n) => Some(n.syntax().clone()), + ast::String(n) => Some(n.syntax().clone()), + ast::Dynamic(n) => Some(n.syntax().clone()), + _ => None, + } + } + })?; + + // Try to find the outermost Attr. + // In case of `{ ${("foo")} = 1; }` + if node.kind() == SyntaxKind::STRING + && matches!(node.parent(), Some(p) if p.kind() == SyntaxKind::PAREN) + { + loop { + node = node.parent()?; + match node.kind() { + SyntaxKind::DYNAMIC => break, + SyntaxKind::PAREN => {} + _ => return None, + } + } + } + let ptr = AstPtr::new(&node); + + let source_map = db.source_map(file_id); + if let Some(name) = source_map.node_name(ptr.clone()) { + return Some((ptr.text_range(), name)); + } + + if let Some(expr) = source_map.node_expr(ptr.clone()) { + let nameres = db.name_resolution(file_id); + if let Some(ResolveResult::Definition(name)) = nameres.get(expr) { + return Some((ptr.text_range(), *name)); + } + } + + None +} + +fn is_valid_ident(name: &str) -> bool { + const KEYWORDS: &[&[u8]] = &[ + b"assert", b"else", b"if", b"in", b"inherit", b"let", b"or", b"rec", b"then", b"with", + ]; + + let bytes = name.as_bytes(); + !name.is_empty() + && name.is_ascii() + && (bytes[0].is_ascii_alphabetic() || bytes[0] == b'_') + && bytes[1..] + .iter() + .all(|&b| b.is_ascii_alphanumeric() || b == b'_' || b == b'\'' || b == b'-') + && !KEYWORDS.contains(&bytes) +} + +#[cfg(test)] +mod tests { + use crate::base::SourceDatabase; + use crate::tests::TestDB; + use expect_test::{expect, Expect}; + + fn check_prepare(fixture: &str, expect: Expect) { + let (db, f) = TestDB::from_fixture(fixture).unwrap(); + let mut src = db.file_content(f[0].file_id).to_string(); + let ret = match super::prepare_rename(&db, f[0]) { + Ok((range, text)) => { + let is_same = src[range] == text; + src.insert(usize::from(range.end()), '>'); + src.insert(usize::from(range.start()), '<'); + if is_same { + src + } else { + format!("{}\n{}\n", src, text) + } + } + Err(err) => err, + }; + expect.assert_eq(&ret); + } + + fn check(fixture: &str, new_name: &str, expect: Expect) { + let (db, f) = TestDB::from_fixture(fixture).unwrap(); + let mut src = db.file_content(f[0].file_id).to_string(); + let ret = match super::rename(&db, f[0], new_name) { + Ok(ws_edit) => { + let edits = ws_edit.content_edits.into_iter().collect::>(); + assert_eq!(edits[0].0, f[0].file_id); + for edit in edits[0].1.iter().rev() { + edit.apply(&mut src); + } + src + } + Err(err) => err, + }; + expect.assert_eq(&ret); + } + + #[test] + fn prepare_ident() { + check_prepare("let $0a = a; in a", expect!["let = a; in a"]); + check_prepare("let a = $0a; in a", expect!["let a = ; in a"]); + check_prepare("{ $0a = 1; }", expect!["{ = 1; }"]); + check_prepare("rec { $0a = 1; }", expect!["rec { = 1; }"]); + check_prepare("{ a.$0b.c = 1; }", expect!["{ a..c = 1; }"]); + } + + #[test] + fn prepare_string() { + check_prepare( + r#"let $0"a" = a; in a"#, + expect![[r#" + let <"a"> = a; in a + a + "#]], + ); + check_prepare( + r#"let a = a; in { inherit $0"a"; }"#, + expect![[r#" + let a = a; in { inherit <"a">; } + a + "#]], + ); + } + + #[test] + fn prepare_dynamic() { + check_prepare( + r#"let ${(($0"a"))} = a; in a"#, + expect![[r#" + let <${(("a"))}> = a; in a + a + "#]], + ); + check_prepare( + r#"let ${($0("a"))} = a; in a"#, + expect![[r#" + let <${(("a"))}> = a; in a + a + "#]], + ); + check_prepare( + r#"let $0${(("a"))} = a; in a"#, + expect![[r#" + let <${(("a"))}> = a; in a + a + "#]], + ); + check_prepare( + r#"let a = a; in { inherit ${(($0"a"))}; }"#, + expect![[r#" + let a = a; in { inherit <${(("a"))}>; } + a + "#]], + ); + } + + #[test] + fn rename_let_ident() { + check( + "let $0a = a; in { a = a; }", + "b", + expect!["let b = b; in { a = b; }"], + ); + check( + "let a = $0a; in { a = a; }", + "b", + expect!["let b = b; in { a = b; }"], + ); + } + + #[test] + fn rename_let_string() { + check( + r#"let $0"a" = a; in { a = a; }"#, + "b", + expect!["let b = b; in { a = b; }"], + ); + check( + r#"let "a" = $0a; in { a = a; }"#, + "b", + expect!["let b = b; in { a = b; }"], + ); + } + + #[test] + fn rename_let_dynamic() { + check( + r#"let $0${"a"} = a; in { a = a; }"#, + "b", + expect!["let b = b; in { a = b; }"], + ); + check( + r#"let ${"a"} = $0a; in { a = a; }"#, + "b", + expect!["let b = b; in { a = b; }"], + ); + } + + #[test] + fn rename_plain_attrset() { + check( + "let a = 1; in { $0a = a; }", + "b", + expect!["let a = 1; in { b = a; }"], + ); + } + + #[test] + fn rename_rec_attrset() { + check( + "let a = 1; in rec { $0a = a; }", + "b", + expect!["let a = 1; in rec { b = b; }"], + ); + } + + #[test] + fn rename_lambda_param() { + check( + "{ $0a ? b, b ? a }@c: c", + "x", + expect!["{ x ? b, b ? x }@c: c"], + ); + check( + "{ a ? $0b, b ? a }@c: c", + "x", + expect!["{ a ? x, x ? a }@c: c"], + ); + check( + "{ a ? b, b ? a }@$0c: c", + "x", + expect!["{ a ? b, b ? a }@x: x"], + ); + check( + "{ a ? b, b ? a }@c: $0c", + "x", + expect!["{ a ? b, b ? a }@x: x"], + ); + } + + #[test] + fn rename_merged() { + check( + "{ a.b.c = 1; a.$0b.d = 2; a.b = { c = 3; }; }", + "x", + expect!["{ a.x.c = 1; a.x.d = 2; a.x = { c = 3; }; }"], + ); + + check( + "{ a = rec { $0b = c; }; a.c = b; }", + "x", + expect!["{ a = rec { x = c; }; a.c = x; }"], + ); + check( + "{ a = rec { b = $0c; }; a.c = b; }", + "x", + expect!["{ a = rec { b = x; }; a.x = b; }"], + ); + } +} diff --git a/crates/ide/src/lib.rs b/crates/ide/src/lib.rs index 4cefa6b..a2f7013 100644 --- a/crates/ide/src/lib.rs +++ b/crates/ide/src/lib.rs @@ -3,6 +3,7 @@ mod builtin; mod def; mod diagnostic; mod ide; +mod text_edit; #[cfg(test)] mod tests; @@ -17,3 +18,4 @@ pub use base::{ }; pub use def::{DefDatabase, Module, ModuleSourceMap}; pub use diagnostic::{Diagnostic, DiagnosticKind, Severity}; +pub use text_edit::{TextEdit, WorkspaceEdit}; diff --git a/crates/ide/src/text_edit.rs b/crates/ide/src/text_edit.rs new file mode 100644 index 0000000..2bccddd --- /dev/null +++ b/crates/ide/src/text_edit.rs @@ -0,0 +1,23 @@ +use crate::FileId; +use smol_str::SmolStr; +use std::collections::HashMap; +use syntax::TextRange; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceEdit { + pub content_edits: HashMap>, + // Filesystem edit is not implemented yet. +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TextEdit { + pub delete: TextRange, + pub insert: SmolStr, +} + +impl TextEdit { + pub fn apply(&self, src: &mut String) { + let delete_range = usize::from(self.delete.start())..usize::from(self.delete.end()); + src.replace_range(delete_range, &self.insert) + } +} diff --git a/crates/nil/src/convert.rs b/crates/nil/src/convert.rs index 90fc743..738448b 100644 --- a/crates/nil/src/convert.rs +++ b/crates/nil/src/convert.rs @@ -1,5 +1,10 @@ -use crate::{LineMap, Result, StateSnapshot, Vfs}; -use ide::{CompletionItem, CompletionItemKind, Diagnostic, FileId, FilePos, FileRange, Severity}; +use crate::{LineMap, LspError, Result, StateSnapshot, Vfs}; +use ide::{ + CompletionItem, CompletionItemKind, Diagnostic, FileId, FilePos, FileRange, Severity, TextEdit, + WorkspaceEdit, +}; +use lsp::PrepareRenameResponse; +use lsp_server::ErrorCode; use lsp_types::{ self as lsp, DiagnosticRelatedInformation, DiagnosticSeverity, DiagnosticTag, Location, Position, Range, TextDocumentIdentifier, TextDocumentPositionParams, @@ -136,3 +141,54 @@ pub(crate) fn to_completion_item(line_map: &LineMap, item: CompletionItem) -> ls ..Default::default() } } + +pub(crate) fn to_rename_error(message: String) -> LspError { + LspError { + code: ErrorCode::InvalidRequest, + message, + } +} + +pub(crate) fn to_prepare_rename_response( + vfs: &Vfs, + file: FileId, + range: TextRange, + text: String, +) -> PrepareRenameResponse { + let line_map = vfs.file_line_map(file); + let range = to_range(line_map, range); + PrepareRenameResponse::RangeWithPlaceholder { + range, + placeholder: text, + } +} + +pub(crate) fn to_workspace_edit(vfs: &Vfs, ws_edit: WorkspaceEdit) -> lsp::WorkspaceEdit { + let content_edits = ws_edit + .content_edits + .into_iter() + .map(|(file, edits)| { + let uri = vfs.uri_for_file(file); + let edits = edits + .into_iter() + .map(|edit| { + let line_map = vfs.file_line_map(file); + to_text_edit(line_map, edit) + }) + .collect(); + (uri, edits) + }) + .collect(); + lsp::WorkspaceEdit { + changes: Some(content_edits), + document_changes: None, + change_annotations: None, + } +} + +pub(crate) fn to_text_edit(line_map: &LineMap, edit: TextEdit) -> lsp::TextEdit { + lsp::TextEdit { + range: to_range(line_map, edit.delete), + new_text: edit.insert.into(), + } +} diff --git a/crates/nil/src/handler.rs b/crates/nil/src/handler.rs index 889b698..e81db5a 100644 --- a/crates/nil/src/handler.rs +++ b/crates/nil/src/handler.rs @@ -2,9 +2,10 @@ use crate::{convert, Result, StateSnapshot}; use ide::FileRange; use lsp_types::{ CompletionOptions, CompletionParams, CompletionResponse, GotoDefinitionParams, - GotoDefinitionResponse, Location, OneOf, ReferenceParams, SelectionRange, SelectionRangeParams, - SelectionRangeProviderCapability, ServerCapabilities, TextDocumentSyncCapability, - TextDocumentSyncKind, TextDocumentSyncOptions, + GotoDefinitionResponse, Location, OneOf, PrepareRenameResponse, ReferenceParams, RenameOptions, + RenameParams, SelectionRange, SelectionRangeParams, SelectionRangeProviderCapability, + ServerCapabilities, TextDocumentPositionParams, TextDocumentSyncCapability, + TextDocumentSyncKind, TextDocumentSyncOptions, WorkDoneProgressOptions, WorkspaceEdit, }; use text_size::TextRange; @@ -24,6 +25,10 @@ pub(crate) fn server_capabilities() -> ServerCapabilities { }), references_provider: Some(OneOf::Left(true)), selection_range_provider: Some(SelectionRangeProviderCapability::Simple(true)), + rename_provider: Some(OneOf::Right(RenameOptions { + prepare_provider: Some(true), + work_done_progress_options: WorkDoneProgressOptions::default(), + })), ..Default::default() } } @@ -118,3 +123,28 @@ pub(crate) fn selection_range( .collect::>>(); ret.map(Some) } + +pub(crate) fn prepare_rename( + snap: StateSnapshot, + params: TextDocumentPositionParams, +) -> Result> { + let fpos = convert::from_file_pos(&snap, ¶ms)?; + let (range, text) = snap + .analysis + .prepare_rename(fpos)? + .map_err(convert::to_rename_error)?; + let vfs = snap.vfs.read().unwrap(); + let resp = convert::to_prepare_rename_response(&vfs, fpos.file_id, range, text.into()); + Ok(Some(resp)) +} + +pub(crate) fn rename(snap: StateSnapshot, params: RenameParams) -> Result> { + let fpos = convert::from_file_pos(&snap, ¶ms.text_document_position)?; + let ws_edit = snap + .analysis + .rename(fpos, ¶ms.new_name)? + .map_err(convert::to_rename_error)?; + let vfs = snap.vfs.read().unwrap(); + let resp = convert::to_workspace_edit(&vfs, ws_edit); + Ok(Some(resp)) +} diff --git a/crates/nil/src/lib.rs b/crates/nil/src/lib.rs index 5aaf6db..ef773d2 100644 --- a/crates/nil/src/lib.rs +++ b/crates/nil/src/lib.rs @@ -3,14 +3,28 @@ mod handler; mod state; mod vfs; -use lsp_server::Connection; +use lsp_server::{Connection, ErrorCode}; use lsp_types::InitializeParams; -use std::env; use std::path::PathBuf; +use std::{env, fmt}; pub(crate) use state::{State, StateSnapshot}; pub(crate) use vfs::{LineMap, Vfs}; +#[derive(Debug)] +pub(crate) struct LspError { + code: ErrorCode, + message: String, +} + +impl fmt::Display for LspError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}: {}", self.code, self.message) + } +} + +impl std::error::Error for LspError {} + pub type Error = Box; pub type Result = std::result::Result; diff --git a/crates/nil/src/state.rs b/crates/nil/src/state.rs index 1863587..14988e5 100644 --- a/crates/nil/src/state.rs +++ b/crates/nil/src/state.rs @@ -89,6 +89,8 @@ impl State { .on::(handler::references) .on::(handler::completion) .on::(handler::selection_range) + .on::(handler::prepare_rename) + .on::(handler::rename) .finish(); }