From e068c7b4b45f83da9e8856dddd8018c7bf9c6acb Mon Sep 17 00:00:00 2001 From: Osvaldo Date: Mon, 17 Feb 2025 21:55:48 +0000 Subject: [PATCH] vim: Update anyquotes and anybrackets to behave like mini.ai plugin (#24167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Overview This PR improves the existing [mini.ai‐like](https://github.com/echasnovski/mini.ai) text-object logic for both “AnyQuotes” (quotes) and “AnyBrackets” (brackets) by adding a multi‐line fallback. The first pass searches only the current line for a best match (cover or next); if none are found, we do a multi‐line pass. This preserves mini.ai's usual “line priority” while ensuring we can detect pairs that start on one line and end on another. ### What Changed 1. Brackets - Line-based pass uses `gather_line_brackets(map, caret.row()) `to find bracket pairs `((), [], {}, <>) `on the caret’s line. - If that fails, we call `gather_brackets_multiline(map)` to single‐pass scan the entire buffer, collecting bracket pairs that might span multiple lines. - Finally, we apply the mini.ai “**cover or next**” logic (`pick_best_range`) to choose the best. 2. Quotes - Similar line-based pass with `gather_line_quotes(map, caret.row())`. - If no local quotes found, we do a multi‐line fallback with `gather_quotes_multiline(map)`, building a big string for the whole buffer and using naive regex for "...", '...', and `...`. - Also preserves “inner vs. outer” logic: - For inner (e.g. `ciq`), we skip bounding quotes or brackets if the range is at least 2 characters wide. - For outer (`caq`), we return the entire range. 3. Shared “`finalize`” helpers - `finalize_bracket_range` and `finalize_quote_range` handle the “inner” skip‐chars vs. “outer” logic. - Both rely on the same “line first, then full fallback” approach. ### Why This Matters - **Old Behavior**: If you had multi‐line brackets { ... } or multi‐line quotes spanning multiple lines, they weren’t found at all, since we only scanned line by line. That made text objects like ci{ or ciq fail in multi-line scenarios. - **New Behavior**: We still do a quick line pass (for user‐friendly “line priority”), but now if that fails, we do a single‐pass approach across the entire buffer. This detects multi‐line pairs and maintains mini.ai’s “cover‐or‐next” picking logic. ### Example Use Cases - **Curly braces:** e.g., opening { on line 10, closing } on line 15 → previously missed; now recognized. - **Multi‐line quotes**: e.g., "'Line 1\nLine 2', no longer missed. We do gather_quotes_multiline with a naive regex matching across newlines. ### Tests - Updated and expanded coverage in: - test_anyquotes_object: - Includes a multi-line '...' test case. - E.g. 'first' false\nstring 'second' → ensuring we detect multi‐line quotes. - test_anybrackets_object: - Verifies line‐based priority but also multi‐line bracket detection. - E.g., an open bracket ( on line 3, close ) on line 5, which used to fail. ### Limitations / Future Enhancements - **Escaping**: The current approach for quotes is naive and doesn’t handle escape sequences (like \") or advanced parser logic. For deeper correctness, we’ll need more advanced logic, this is also not supported in the original mini.ai plugin so it is a known issue that won't be attended for now. ### Important Notes - Fix for the bug: https://github.com/zed-industries/zed/issues/23889 this PR addresses that bug specifically for the AnyQuotes text object. Note that the issue still remains in the built-in motions (ci', ci", ci`). - Caret Position Differences: The caret position now slightly deviates from Vim’s default behavior. This is intentional. I aim to closely mimic the mini.ai plugin. Because these text objects are optional (configurable via vim.json), this adjusted behavior is considered acceptable and in my opinion the new behavior is better and it should be the default in vim. Please review the new tests for details and context. - Improved Special Cases: I’ve also refined how “false strings” in the middle and certain curly-bracket scenarios are handled. The test suite reflects these improvements, resulting in a more seamless coding experience overall. ### References: - Mini.AI plugin in nvim: https://github.com/echasnovski/mini.ai Thank you for reviewing these changes! Release Notes: - Improve logic of aq, iq, ab and ib motions to work more like mini.ai plugin --- .../src/test/editor_lsp_test_context.rs | 2 + crates/languages/src/bash/brackets.scm | 3 + crates/languages/src/c/brackets.scm | 1 + crates/languages/src/cpp/brackets.scm | 1 + crates/languages/src/css/brackets.scm | 2 + crates/languages/src/go/brackets.scm | 2 + crates/languages/src/javascript/brackets.scm | 2 + crates/languages/src/markdown/brackets.scm | 7 + crates/languages/src/python/brackets.scm | 1 + crates/languages/src/rust/brackets.scm | 1 + crates/languages/src/tsx/brackets.scm | 2 + crates/languages/src/typescript/brackets.scm | 2 + crates/languages/src/yaml/brackets.scm | 1 + crates/vim/src/object.rs | 376 ++++++++++++++---- 14 files changed, 323 insertions(+), 80 deletions(-) create mode 100644 crates/languages/src/markdown/brackets.scm diff --git a/crates/editor/src/test/editor_lsp_test_context.rs b/crates/editor/src/test/editor_lsp_test_context.rs index a0357b9ded..8a48700adc 100644 --- a/crates/editor/src/test/editor_lsp_test_context.rs +++ b/crates/editor/src/test/editor_lsp_test_context.rs @@ -215,6 +215,8 @@ impl EditorLspTestContext { ("[" @open "]" @close) ("{" @open "}" @close) ("<" @open ">" @close) + ("'" @open "'" @close) + ("`" @open "`" @close) ("\"" @open "\"" @close)"#})), indents: Some(Cow::from(indoc! {r#" [ diff --git a/crates/languages/src/bash/brackets.scm b/crates/languages/src/bash/brackets.scm index 191fd9c084..c61aaa1037 100644 --- a/crates/languages/src/bash/brackets.scm +++ b/crates/languages/src/bash/brackets.scm @@ -1,3 +1,6 @@ ("(" @open ")" @close) ("[" @open "]" @close) ("{" @open "}" @close) +("\"" @open "\"" @close) +("`" @open "`" @close) +((raw_string) @open @close) diff --git a/crates/languages/src/c/brackets.scm b/crates/languages/src/c/brackets.scm index 2fbfd44362..2f886c4240 100644 --- a/crates/languages/src/c/brackets.scm +++ b/crates/languages/src/c/brackets.scm @@ -2,3 +2,4 @@ ("[" @open "]" @close) ("{" @open "}" @close) ("\"" @open "\"" @close) +("'" @open "'" @close) diff --git a/crates/languages/src/cpp/brackets.scm b/crates/languages/src/cpp/brackets.scm index 2fbfd44362..2f886c4240 100644 --- a/crates/languages/src/cpp/brackets.scm +++ b/crates/languages/src/cpp/brackets.scm @@ -2,3 +2,4 @@ ("[" @open "]" @close) ("{" @open "}" @close) ("\"" @open "\"" @close) +("'" @open "'" @close) diff --git a/crates/languages/src/css/brackets.scm b/crates/languages/src/css/brackets.scm index 191fd9c084..2f886c4240 100644 --- a/crates/languages/src/css/brackets.scm +++ b/crates/languages/src/css/brackets.scm @@ -1,3 +1,5 @@ ("(" @open ")" @close) ("[" @open "]" @close) ("{" @open "}" @close) +("\"" @open "\"" @close) +("'" @open "'" @close) diff --git a/crates/languages/src/go/brackets.scm b/crates/languages/src/go/brackets.scm index 2fbfd44362..0ced37682d 100644 --- a/crates/languages/src/go/brackets.scm +++ b/crates/languages/src/go/brackets.scm @@ -2,3 +2,5 @@ ("[" @open "]" @close) ("{" @open "}" @close) ("\"" @open "\"" @close) +("`" @open "`" @close) +((rune_literal) @open @close) diff --git a/crates/languages/src/javascript/brackets.scm b/crates/languages/src/javascript/brackets.scm index 63395f81d8..48afefeef0 100644 --- a/crates/languages/src/javascript/brackets.scm +++ b/crates/languages/src/javascript/brackets.scm @@ -3,3 +3,5 @@ ("{" @open "}" @close) ("<" @open ">" @close) ("\"" @open "\"" @close) +("'" @open "'" @close) +("`" @open "`" @close) diff --git a/crates/languages/src/markdown/brackets.scm b/crates/languages/src/markdown/brackets.scm new file mode 100644 index 0000000000..23f3e4d3d0 --- /dev/null +++ b/crates/languages/src/markdown/brackets.scm @@ -0,0 +1,7 @@ +("(" @open ")" @close) +("[" @open "]" @close) +("{" @open "}" @close) +("\"" @open "\"" @close) +("`" @open "`" @close) +("'" @open "'" @close) +((fenced_code_block_delimiter) @open (fenced_code_block_delimiter) @close) diff --git a/crates/languages/src/python/brackets.scm b/crates/languages/src/python/brackets.scm index 191fd9c084..be68033587 100644 --- a/crates/languages/src/python/brackets.scm +++ b/crates/languages/src/python/brackets.scm @@ -1,3 +1,4 @@ ("(" @open ")" @close) ("[" @open "]" @close) ("{" @open "}" @close) +((string_start) @open (string_end) @close) diff --git a/crates/languages/src/rust/brackets.scm b/crates/languages/src/rust/brackets.scm index eeee5f0e26..0bf19b8085 100644 --- a/crates/languages/src/rust/brackets.scm +++ b/crates/languages/src/rust/brackets.scm @@ -4,3 +4,4 @@ ("<" @open ">" @close) ("\"" @open "\"" @close) (closure_parameters "|" @open "|" @close) +("'" @open "'" @close) diff --git a/crates/languages/src/tsx/brackets.scm b/crates/languages/src/tsx/brackets.scm index 7a02f815c0..66bf14f137 100644 --- a/crates/languages/src/tsx/brackets.scm +++ b/crates/languages/src/tsx/brackets.scm @@ -5,3 +5,5 @@ ("<" @open "/>" @close) ("" @close) ("\"" @open "\"" @close) +("'" @open "'" @close) +("`" @open "`" @close) diff --git a/crates/languages/src/typescript/brackets.scm b/crates/languages/src/typescript/brackets.scm index 63395f81d8..48afefeef0 100644 --- a/crates/languages/src/typescript/brackets.scm +++ b/crates/languages/src/typescript/brackets.scm @@ -3,3 +3,5 @@ ("{" @open "}" @close) ("<" @open ">" @close) ("\"" @open "\"" @close) +("'" @open "'" @close) +("`" @open "`" @close) diff --git a/crates/languages/src/yaml/brackets.scm b/crates/languages/src/yaml/brackets.scm index 9e8c9cd93c..59cf45205f 100644 --- a/crates/languages/src/yaml/brackets.scm +++ b/crates/languages/src/yaml/brackets.scm @@ -1,3 +1,4 @@ ("[" @open "]" @close) ("{" @open "}" @close) ("\"" @open "\"" @close) +("'" @open "'" @close) diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs index 06b40587c4..9218c6dff6 100644 --- a/crates/vim/src/object.rs +++ b/crates/vim/src/object.rs @@ -8,7 +8,7 @@ use crate::{ use editor::{ display_map::{DisplaySnapshot, ToDisplayPoint}, movement::{self, FindRange}, - Bias, DisplayPoint, Editor, + Bias, DisplayPoint, Editor, ToOffset, }; use gpui::{actions, impl_actions, Window}; use itertools::Itertools; @@ -64,6 +64,192 @@ struct IndentObj { include_below: bool, } +#[derive(Debug, Clone)] +pub struct CandidateRange { + pub start: DisplayPoint, + pub end: DisplayPoint, +} + +#[derive(Debug, Clone)] +pub struct CandidateWithRanges { + candidate: CandidateRange, + open_range: Range, + close_range: Range, +} + +fn cover_or_next, Range)>>( + candidates: Option, + caret: DisplayPoint, + map: &DisplaySnapshot, + range_filter: Option<&dyn Fn(Range, Range) -> bool>, +) -> Option { + let caret_offset = caret.to_offset(map, Bias::Left); + let mut covering = vec![]; + let mut next_ones = vec![]; + let snapshot = &map.buffer_snapshot; + + if let Some(ranges) = candidates { + for (open_range, close_range) in ranges { + let start_off = open_range.start; + let end_off = close_range.end; + if let Some(range_filter) = range_filter { + if !range_filter(open_range.clone(), close_range.clone()) { + continue; + } + } + let candidate = CandidateWithRanges { + candidate: CandidateRange { + start: start_off.to_display_point(map), + end: end_off.to_display_point(map), + }, + open_range: open_range.clone(), + close_range: close_range.clone(), + }; + + if open_range + .start + .to_offset(snapshot) + .to_display_point(map) + .row() + == caret_offset.to_display_point(map).row() + { + if start_off <= caret_offset && caret_offset < end_off { + covering.push(candidate); + } else if start_off >= caret_offset { + next_ones.push(candidate); + } + } + } + } + + // 1) covering -> smallest width + if !covering.is_empty() { + return covering.into_iter().min_by_key(|r| { + r.candidate.end.to_offset(map, Bias::Right) + - r.candidate.start.to_offset(map, Bias::Left) + }); + } + + // 2) next -> closest by start + if !next_ones.is_empty() { + return next_ones.into_iter().min_by_key(|r| { + let start = r.candidate.start.to_offset(map, Bias::Left); + (start as isize - caret_offset as isize).abs() + }); + } + + None +} + +type DelimiterPredicate = dyn Fn(&BufferSnapshot, usize, usize) -> bool; + +struct DelimiterRange { + open: Range, + close: Range, +} + +impl DelimiterRange { + fn to_display_range(&self, map: &DisplaySnapshot, around: bool) -> Range { + if around { + self.open.start.to_display_point(map)..self.close.end.to_display_point(map) + } else { + self.open.end.to_display_point(map)..self.close.start.to_display_point(map) + } + } +} + +fn find_any_delimiters( + map: &DisplaySnapshot, + display_point: DisplayPoint, + around: bool, + is_valid_delimiter: &DelimiterPredicate, +) -> Option> { + let point = map.clip_at_line_end(display_point).to_point(map); + let offset = point.to_offset(&map.buffer_snapshot); + + let line_range = get_line_range(map, point); + let visible_line_range = get_visible_line_range(&line_range); + + let snapshot = &map.buffer_snapshot; + let excerpt = snapshot.excerpt_containing(offset..offset)?; + let buffer = excerpt.buffer(); + + let bracket_filter = |open: Range, close: Range| { + is_valid_delimiter(buffer, open.start, close.start) + }; + + // Try to find delimiters in visible range first + let ranges = map + .buffer_snapshot + .bracket_ranges(visible_line_range.clone()); + if let Some(candidate) = cover_or_next(ranges, display_point, map, Some(&bracket_filter)) { + return Some( + DelimiterRange { + open: candidate.open_range, + close: candidate.close_range, + } + .to_display_range(map, around), + ); + } + + // Fall back to innermost enclosing brackets + let (open_bracket, close_bracket) = + buffer.innermost_enclosing_bracket_ranges(offset..offset, Some(&bracket_filter))?; + + Some( + DelimiterRange { + open: open_bracket, + close: close_bracket, + } + .to_display_range(map, around), + ) +} + +fn get_line_range(map: &DisplaySnapshot, point: Point) -> Range { + let (start, mut end) = ( + map.prev_line_boundary(point).0, + map.next_line_boundary(point).0, + ); + + if end == point { + end = map.max_point().to_point(map); + } + + start..end +} + +fn get_visible_line_range(line_range: &Range) -> Range { + let end_column = line_range.end.column.saturating_sub(1); + line_range.start..Point::new(line_range.end.row, end_column) +} + +fn is_quote_delimiter(buffer: &BufferSnapshot, _start: usize, end: usize) -> bool { + matches!(buffer.chars_at(end).next(), Some('\'' | '"' | '`')) +} + +fn is_bracket_delimiter(buffer: &BufferSnapshot, start: usize, _end: usize) -> bool { + matches!( + buffer.chars_at(start).next(), + Some('(' | '[' | '{' | '<' | '|') + ) +} + +fn find_any_quotes( + map: &DisplaySnapshot, + display_point: DisplayPoint, + around: bool, +) -> Option> { + find_any_delimiters(map, display_point, around, &is_quote_delimiter) +} + +fn find_any_brackets( + map: &DisplaySnapshot, + display_point: DisplayPoint, + around: bool, +) -> Option> { + find_any_delimiters(map, display_point, around, &is_bracket_delimiter) +} + impl_actions!(vim, [Word, Subword, IndentObj]); actions!( @@ -304,26 +490,7 @@ impl Object { Object::BackQuotes => { surrounding_markers(map, relative_to, around, self.is_multiline(), '`', '`') } - Object::AnyQuotes => { - let quote_types = ['\'', '"', '`']; // Types of quotes to handle - let relative_offset = relative_to.to_offset(map, Bias::Left) as isize; - - // Find the closest matching quote range - quote_types - .iter() - .flat_map(|"e| { - // Get ranges for each quote type - surrounding_markers( - map, - relative_to, - around, - self.is_multiline(), - quote, - quote, - ) - }) - .min_by_key(|range| calculate_range_distance(range, relative_offset, map)) - } + Object::AnyQuotes => find_any_quotes(map, relative_to, around), Object::DoubleQuotes => { surrounding_markers(map, relative_to, around, self.is_multiline(), '"', '"') } @@ -338,24 +505,7 @@ impl Object { let range = selection.range(); surrounding_html_tag(map, head, range, around) } - Object::AnyBrackets => { - let bracket_pairs = [('(', ')'), ('[', ']'), ('{', '}'), ('<', '>')]; - let relative_offset = relative_to.to_offset(map, Bias::Left) as isize; - - bracket_pairs - .iter() - .flat_map(|&(open_bracket, close_bracket)| { - surrounding_markers( - map, - relative_to, - around, - self.is_multiline(), - open_bracket, - close_bracket, - ) - }) - .min_by_key(|range| calculate_range_distance(range, relative_offset, map)) - } + Object::AnyBrackets => find_any_brackets(map, relative_to, around), Object::SquareBrackets => { surrounding_markers(map, relative_to, around, self.is_multiline(), '[', ']') } @@ -657,37 +807,6 @@ fn around_word( } } -/// Calculate distance between a range and a cursor position -/// -/// Returns a score where: -/// - Lower values indicate better matches -/// - Range containing cursor gets priority (returns range length) -/// - For non-containing ranges, uses minimum distance to boundaries as primary factor -/// - Range length is used as secondary factor for tiebreaking -fn calculate_range_distance( - range: &Range, - cursor_offset: isize, - map: &DisplaySnapshot, -) -> isize { - let start_offset = range.start.to_offset(map, Bias::Left) as isize; - let end_offset = range.end.to_offset(map, Bias::Right) as isize; - let range_length = end_offset - start_offset; - - // If cursor is inside the range, return range length - if cursor_offset >= start_offset && cursor_offset <= end_offset { - return range_length; - } - - // Calculate minimum distance to range boundaries - let start_distance = (cursor_offset - start_offset).abs(); - let end_distance = (cursor_offset - end_offset).abs(); - let min_distance = start_distance.min(end_distance); - - // Use min_distance as primary factor, range_length as secondary - // Multiply by large number to ensure distance is primary factor - min_distance * 10000 + range_length -} - fn around_subword( map: &DisplaySnapshot, relative_to: DisplayPoint, @@ -2098,9 +2217,36 @@ mod test { #[gpui::test] async fn test_anyquotes_object(cx: &mut gpui::TestAppContext) { - let mut cx = VimTestContext::new(cx, true).await; + let mut cx = VimTestContext::new_typescript(cx).await; const TEST_CASES: &[(&str, &str, &str, Mode)] = &[ + // Special cases from mini.ai plugin + // the false string in the middle should not be considered + ( + "c i q", + "'first' false ˇstring 'second'", + "'first' false string 'ˇ'", + Mode::Insert, + ), + // Multiline support :)! Same behavior as mini.ai plugin + ( + "c i q", + indoc! {" + ` + first + middle ˇstring + second + ` + "}, + indoc! {" + `ˇ` + "}, + Mode::Insert, + ), + // If you are in the close quote and it is the only quote in the buffer, it should replace inside the quote + // This is not working with the core motion ci' for this special edge case, so I am happy to fix it in AnyQuotes :) + // Bug reference: https://github.com/zed-industries/zed/issues/23889 + ("c i q", "'quote«'ˇ»", "'ˇ'", Mode::Insert), // Single quotes ( "c i q", @@ -2111,19 +2257,19 @@ mod test { ( "c a q", "Thisˇ is a 'quote' example.", - "This is a ˇexample.", + "This is a ˇ example.", // same mini.ai plugin behavior Mode::Insert, ), ( "c i q", "This is a \"simple 'qˇuote'\" example.", - "This is a \"simple 'ˇ'\" example.", + "This is a \"ˇ\" example.", // Not supported by tree sitter queries for now Mode::Insert, ), ( "c a q", "This is a \"simple 'qˇuote'\" example.", - "This is a \"simpleˇ\" example.", + "This is a ˇ example.", // Not supported by tree sitter queries for now Mode::Insert, ), ( @@ -2135,7 +2281,7 @@ mod test { ( "c a q", "This is a 'qˇuote' example.", - "This is a ˇexample.", + "This is a ˇ example.", // same mini.ai plugin behavior Mode::Insert, ), ( @@ -2147,7 +2293,7 @@ mod test { ( "d a q", "This is a 'qˇuote' example.", - "This is a ˇexample.", + "This is a ˇ example.", // same mini.ai plugin behavior Mode::Normal, ), // Double quotes @@ -2160,7 +2306,7 @@ mod test { ( "c a q", "This is a \"qˇuote\" example.", - "This is a ˇexample.", + "This is a ˇ example.", // same mini.ai plugin behavior Mode::Insert, ), ( @@ -2172,7 +2318,7 @@ mod test { ( "d a q", "This is a \"qˇuote\" example.", - "This is a ˇexample.", + "This is a ˇ example.", // same mini.ai plugin behavior Mode::Normal, ), // Back quotes @@ -2185,7 +2331,7 @@ mod test { ( "c a q", "This is a `qˇuote` example.", - "This is a ˇexample.", + "This is a ˇ example.", // same mini.ai plugin behavior Mode::Insert, ), ( @@ -2197,7 +2343,7 @@ mod test { ( "d a q", "This is a `qˇuote` example.", - "This is a ˇexample.", + "This is a ˇ example.", // same mini.ai plugin behavior Mode::Normal, ), ]; @@ -2246,6 +2392,76 @@ mod test { }); const TEST_CASES: &[(&str, &str, &str, Mode)] = &[ + // Special cases from mini.ai plugin + // Current line has more priority for the cover or next algorithm, to avoid changing curly brackets which is supper anoying + // Same behavior as mini.ai plugin + ( + "c i b", + indoc! {" + { + { + ˇprint('hello') + } + } + "}, + indoc! {" + { + { + print(ˇ) + } + } + "}, + Mode::Insert, + ), + // If the current line doesn't have brackets then it should consider if the caret is inside an external bracket + // Same behavior as mini.ai plugin + ( + "c i b", + indoc! {" + { + { + ˇ + print('hello') + } + } + "}, + indoc! {" + { + {ˇ} + } + "}, + Mode::Insert, + ), + // If you are in the open bracket then it has higher priority + ( + "c i b", + indoc! {" + «{ˇ» + { + print('hello') + } + } + "}, + indoc! {" + {ˇ} + "}, + Mode::Insert, + ), + // If you are in the close bracket then it has higher priority + ( + "c i b", + indoc! {" + { + { + print('hello') + } + «}ˇ» + "}, + indoc! {" + {ˇ} + "}, + Mode::Insert, + ), // Bracket (Parentheses) ( "c i b",