diff --git a/crates/vim/src/normal/search.rs b/crates/vim/src/normal/search.rs index 549e5666834..e7d17af1e3e 100644 --- a/crates/vim/src/normal/search.rs +++ b/crates/vim/src/normal/search.rs @@ -669,7 +669,9 @@ impl Replacement { // convert a vim query into something more usable by zed. // we don't attempt to fully convert between the two regex syntaxes, // but we do flip \( and \) to ( and ) (and vice-versa) in the pattern, - // and convert \0..\9 to $0..$9 in the replacement so that common idioms work. + // convert \0..\9 to $0..$9 in the replacement so that common idioms work, + // and escape literal `$` to `$$` in the replacement so vim's literal `$` + // is not interpreted as a Rust regex capture-group reference. pub(crate) fn parse(mut chars: Peekable) -> Option { let delimiter = chars .next() @@ -692,6 +694,9 @@ impl Replacement { escaped = false; if phase == 1 && c.is_ascii_digit() { buffer.push('$') + } else if phase == 1 && c == '$' { + // Second '$' escapes by fallthrough + buffer.push('$') // unescape escaped parens } else if phase == 0 && (c == '(' || c == ')') { } else if c != delimiter { @@ -714,6 +719,10 @@ impl Replacement { // escape unescaped parens if phase == 0 && (c == '(' || c == ')') { buffer.push('\\') + } else if phase == 1 && c == '$' { + // '$' is not special in the replacement clause, + // so we also escape here. + buffer.push('$') } buffer.push(c) } @@ -757,6 +766,16 @@ mod test { use search::BufferSearchBar; use settings::SettingsStore; + #[test] + fn test_replacement_parse_escaped_dollar() { + let parsed = super::Replacement::parse(r"/\$test/\$rest/g".chars().peekable()) + .expect("parse should succeed"); + + assert_eq!(parsed.search, r"\$test"); + assert_eq!(parsed.replacement, "$$rest"); + assert!(parsed.flag_g); + } + #[gpui::test] async fn test_move_to_next(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; @@ -1182,6 +1201,27 @@ mod test { }) } + #[gpui::test] + async fn test_replace_literal_dollar(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + cx.set_shared_state(indoc! { + "ˇBase=hello + echo $Base" + }) + .await; + + cx.simulate_shared_keystrokes( + ": % s / \\ $ shift-b a s e / \\ $ shift-b a s e shift-n e w / g", + ) + .await; + cx.simulate_shared_keystrokes("enter").await; + + cx.shared_state().await.assert_eq(indoc! { + "Base=hello + ˇecho $BaseNew" + }); + } + #[gpui::test] async fn test_replace_g(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; diff --git a/crates/vim/test_data/test_replace_literal_dollar.json b/crates/vim/test_data/test_replace_literal_dollar.json new file mode 100644 index 00000000000..b5f97f9505c --- /dev/null +++ b/crates/vim/test_data/test_replace_literal_dollar.json @@ -0,0 +1,25 @@ +{"Put":{"state":"ˇBase=hello\necho $Base"}} +{"Key":":"} +{"Key":"%"} +{"Key":"s"} +{"Key":"/"} +{"Key":"\\"} +{"Key":"$"} +{"Key":"shift-b"} +{"Key":"a"} +{"Key":"s"} +{"Key":"e"} +{"Key":"/"} +{"Key":"\\"} +{"Key":"$"} +{"Key":"shift-b"} +{"Key":"a"} +{"Key":"s"} +{"Key":"e"} +{"Key":"shift-n"} +{"Key":"e"} +{"Key":"w"} +{"Key":"/"} +{"Key":"g"} +{"Key":"enter"} +{"Get":{"state":"Base=hello\nˇecho $BaseNew","mode":"Normal"}}