From 435d4c5f2415f569d192ee27bf8d6ed5157360f6 Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Thu, 18 Dec 2025 20:56:47 -0600 Subject: [PATCH] vim: Make `vaf` include const for arrow functions in JS/TS/TSX (#45327) Closes #24264 Release Notes: - N/A *or* Added/Fixed/Improved ... --- .../src/test/editor_lsp_test_context.rs | 86 ++++ crates/language/src/buffer_tests.rs | 98 +++++ crates/language/src/syntax_map.rs | 61 ++- .../languages/src/javascript/textobjects.scm | 38 +- crates/languages/src/tsx/textobjects.scm | 38 +- .../languages/src/typescript/textobjects.scm | 39 +- crates/vim/src/object.rs | 386 ++++++++++++++++++ crates/vim/src/visual.rs | 16 +- 8 files changed, 742 insertions(+), 20 deletions(-) diff --git a/crates/editor/src/test/editor_lsp_test_context.rs b/crates/editor/src/test/editor_lsp_test_context.rs index 7c4c0e48d36dbb9f74a1c835c63fa2b91c5681d9..3e7c47c2ac5efeedde51f180bcfcb424aec31c86 100644 --- a/crates/editor/src/test/editor_lsp_test_context.rs +++ b/crates/editor/src/test/editor_lsp_test_context.rs @@ -205,6 +205,49 @@ impl EditorLspTestContext { (_ "{" "}" @end) @indent (_ "(" ")" @end) @indent "#})), + text_objects: Some(Cow::from(indoc! {r#" + (function_declaration + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + + (method_definition + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + + ; Arrow function in variable declaration - capture the full declaration + ([ + (lexical_declaration + (variable_declarator + value: (arrow_function + body: (statement_block + "{" + (_)* @function.inside + "}")))) + (variable_declaration + (variable_declarator + value: (arrow_function + body: (statement_block + "{" + (_)* @function.inside + "}")))) + ]) @function.around + + ([ + (lexical_declaration + (variable_declarator + value: (arrow_function))) + (variable_declaration + (variable_declarator + value: (arrow_function))) + ]) @function.around + + ; Catch-all for arrow functions in other contexts (callbacks, etc.) + ((arrow_function) @function.around (#not-has-parent? @function.around variable_declarator)) + "#})), ..Default::default() }) .expect("Could not parse queries"); @@ -276,6 +319,49 @@ impl EditorLspTestContext { (jsx_opening_element) @start (jsx_closing_element)? @end) @indent "#})), + text_objects: Some(Cow::from(indoc! {r#" + (function_declaration + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + + (method_definition + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + + ; Arrow function in variable declaration - capture the full declaration + ([ + (lexical_declaration + (variable_declarator + value: (arrow_function + body: (statement_block + "{" + (_)* @function.inside + "}")))) + (variable_declaration + (variable_declarator + value: (arrow_function + body: (statement_block + "{" + (_)* @function.inside + "}")))) + ]) @function.around + + ([ + (lexical_declaration + (variable_declarator + value: (arrow_function))) + (variable_declaration + (variable_declarator + value: (arrow_function))) + ]) @function.around + + ; Catch-all for arrow functions in other contexts (callbacks, etc.) + ((arrow_function) @function.around (#not-has-parent? @function.around variable_declarator)) + "#})), ..Default::default() }) .expect("Could not parse queries"); diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index 54e2ef4065460547f4a3f86db7d3a3986dff65eb..2c2d93c8239f0f3fcb1de0956de2d3400f13e96b 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -1141,6 +1141,104 @@ fn test_text_objects(cx: &mut App) { ) } +#[gpui::test] +fn test_text_objects_with_has_parent_predicate(cx: &mut App) { + use std::borrow::Cow; + + // Create a language with a custom text_objects query that uses #has-parent? + // This query only matches closure_expression when it's inside a call_expression + let language = Language::new( + LanguageConfig { + name: "Rust".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_queries(LanguageQueries { + text_objects: Some(Cow::from(indoc! {r#" + ; Only match closures that are arguments to function calls + (closure_expression) @function.around + (#has-parent? @function.around arguments) + "#})), + ..Default::default() + }) + .expect("Could not parse queries"); + + let (text, ranges) = marked_text_ranges( + indoc! {r#" + fn main() { + let standalone = |x| x + 1; + let result = foo(|y| y * ˇ2); + }"# + }, + false, + ); + + let buffer = cx.new(|cx| Buffer::local(text.clone(), cx).with_language(Arc::new(language), cx)); + let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); + + let matches = snapshot + .text_object_ranges(ranges[0].clone(), TreeSitterOptions::default()) + .map(|(range, text_object)| (&text[range], text_object)) + .collect::>(); + + // Should only match the closure inside foo(), not the standalone closure + assert_eq!(matches, &[("|y| y * 2", TextObject::AroundFunction),]); +} + +#[gpui::test] +fn test_text_objects_with_not_has_parent_predicate(cx: &mut App) { + use std::borrow::Cow; + + // Create a language with a custom text_objects query that uses #not-has-parent? + // This query only matches closure_expression when it's NOT inside a call_expression + let language = Language::new( + LanguageConfig { + name: "Rust".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_queries(LanguageQueries { + text_objects: Some(Cow::from(indoc! {r#" + ; Only match closures that are NOT arguments to function calls + (closure_expression) @function.around + (#not-has-parent? @function.around arguments) + "#})), + ..Default::default() + }) + .expect("Could not parse queries"); + + let (text, ranges) = marked_text_ranges( + indoc! {r#" + fn main() { + let standalone = |x| x +ˇ 1; + let result = foo(|y| y * 2); + }"# + }, + false, + ); + + let buffer = cx.new(|cx| Buffer::local(text.clone(), cx).with_language(Arc::new(language), cx)); + let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); + + let matches = snapshot + .text_object_ranges(ranges[0].clone(), TreeSitterOptions::default()) + .map(|(range, text_object)| (&text[range], text_object)) + .collect::>(); + + // Should only match the standalone closure, not the one inside foo() + assert_eq!(matches, &[("|x| x + 1", TextObject::AroundFunction),]); +} + #[gpui::test] fn test_enclosing_bracket_ranges(cx: &mut App) { #[track_caller] diff --git a/crates/language/src/syntax_map.rs b/crates/language/src/syntax_map.rs index 77e90c4ca89d0b6e5b8cb0a604175ec9a97e719e..db4ab4f459c35a98752bef1eb5be558084b5c906 100644 --- a/crates/language/src/syntax_map.rs +++ b/crates/language/src/syntax_map.rs @@ -19,7 +19,10 @@ use std::{ use streaming_iterator::StreamingIterator; use sum_tree::{Bias, Dimensions, SeekTarget, SumTree}; use text::{Anchor, BufferSnapshot, OffsetRangeExt, Point, Rope, ToOffset, ToPoint}; -use tree_sitter::{Node, Query, QueryCapture, QueryCaptures, QueryCursor, QueryMatches, Tree}; +use tree_sitter::{ + Node, Query, QueryCapture, QueryCaptures, QueryCursor, QueryMatch, QueryMatches, + QueryPredicateArg, Tree, +}; pub const MAX_BYTES_TO_QUERY: usize = 16 * 1024; @@ -82,6 +85,7 @@ struct SyntaxMapMatchesLayer<'a> { next_captures: Vec>, has_next: bool, matches: QueryMatches<'a, 'a, TextProvider<'a>, &'a [u8]>, + query: &'a Query, grammar_index: usize, _query_cursor: QueryCursorHandle, } @@ -1163,6 +1167,7 @@ impl<'a> SyntaxMapMatches<'a> { depth: layer.depth, grammar_index, matches, + query, next_pattern_index: 0, next_captures: Vec::new(), has_next: false, @@ -1260,13 +1265,20 @@ impl SyntaxMapCapturesLayer<'_> { impl SyntaxMapMatchesLayer<'_> { fn advance(&mut self) { - if let Some(mat) = self.matches.next() { - self.next_captures.clear(); - self.next_captures.extend_from_slice(mat.captures); - self.next_pattern_index = mat.pattern_index; - self.has_next = true; - } else { - self.has_next = false; + loop { + if let Some(mat) = self.matches.next() { + if !satisfies_custom_predicates(self.query, mat) { + continue; + } + self.next_captures.clear(); + self.next_captures.extend_from_slice(mat.captures); + self.next_pattern_index = mat.pattern_index; + self.has_next = true; + return; + } else { + self.has_next = false; + return; + } } } @@ -1295,6 +1307,39 @@ impl<'a> Iterator for SyntaxMapCaptures<'a> { } } +fn satisfies_custom_predicates(query: &Query, mat: &QueryMatch) -> bool { + for predicate in query.general_predicates(mat.pattern_index) { + let satisfied = match predicate.operator.as_ref() { + "has-parent?" => has_parent(&predicate.args, mat), + "not-has-parent?" => !has_parent(&predicate.args, mat), + _ => true, + }; + if !satisfied { + return false; + } + } + true +} + +fn has_parent(args: &[QueryPredicateArg], mat: &QueryMatch) -> bool { + let ( + Some(QueryPredicateArg::Capture(capture_ix)), + Some(QueryPredicateArg::String(parent_kind)), + ) = (args.first(), args.get(1)) + else { + return false; + }; + + let Some(capture) = mat.captures.iter().find(|c| c.index == *capture_ix) else { + return false; + }; + + capture + .node + .parent() + .is_some_and(|p| p.kind() == parent_kind.as_ref()) +} + fn join_ranges( a: impl Iterator>, b: impl Iterator>, diff --git a/crates/languages/src/javascript/textobjects.scm b/crates/languages/src/javascript/textobjects.scm index 1a273ddb5000ba920868272bb4ac31d270095442..eace658e6b9847bcc651deedad2bc27cbfbf6975 100644 --- a/crates/languages/src/javascript/textobjects.scm +++ b/crates/languages/src/javascript/textobjects.scm @@ -18,13 +18,47 @@ (_)* @function.inside "}")) @function.around -(arrow_function +((arrow_function body: (statement_block "{" (_)* @function.inside "}")) @function.around + (#not-has-parent? @function.around variable_declarator)) -(arrow_function) @function.around +; Arrow function in variable declaration - capture the full declaration +([ + (lexical_declaration + (variable_declarator + value: (arrow_function + body: (statement_block + "{" + (_)* @function.inside + "}")))) + (variable_declaration + (variable_declarator + value: (arrow_function + body: (statement_block + "{" + (_)* @function.inside + "}")))) +]) @function.around + +; Arrow function in variable declaration (captures body for expression-bodied arrows) +([ + (lexical_declaration + (variable_declarator + value: (arrow_function + body: (_) @function.inside))) + (variable_declaration + (variable_declarator + value: (arrow_function + body: (_) @function.inside))) +]) @function.around + +; Catch-all for arrow functions in other contexts (callbacks, etc.) +((arrow_function + body: (_) @function.inside) @function.around + (#not-has-parent? @function.around variable_declarator)) (generator_function body: (_ diff --git a/crates/languages/src/tsx/textobjects.scm b/crates/languages/src/tsx/textobjects.scm index 836fed35ba1c1093b84e48a8da19d89177a69944..628a921f3ac9ea04ff59654d72caf73cebbc9071 100644 --- a/crates/languages/src/tsx/textobjects.scm +++ b/crates/languages/src/tsx/textobjects.scm @@ -18,13 +18,47 @@ (_)* @function.inside "}")) @function.around -(arrow_function +((arrow_function body: (statement_block "{" (_)* @function.inside "}")) @function.around + (#not-has-parent? @function.around variable_declarator)) -(arrow_function) @function.around +; Arrow function in variable declaration - capture the full declaration +([ + (lexical_declaration + (variable_declarator + value: (arrow_function + body: (statement_block + "{" + (_)* @function.inside + "}")))) + (variable_declaration + (variable_declarator + value: (arrow_function + body: (statement_block + "{" + (_)* @function.inside + "}")))) +]) @function.around + +; Arrow function in variable declaration (expression body fallback) +([ + (lexical_declaration + (variable_declarator + value: (arrow_function + body: (_) @function.inside))) + (variable_declaration + (variable_declarator + value: (arrow_function + body: (_) @function.inside))) +]) @function.around + +; Catch-all for arrow functions in other contexts (callbacks, etc.) +((arrow_function + body: (_) @function.inside) @function.around + (#not-has-parent? @function.around variable_declarator)) (function_signature) @function.around (generator_function diff --git a/crates/languages/src/typescript/textobjects.scm b/crates/languages/src/typescript/textobjects.scm index 836fed35ba1c1093b84e48a8da19d89177a69944..96289f058cd7b605a8f5b4c8966e3c372022d065 100644 --- a/crates/languages/src/typescript/textobjects.scm +++ b/crates/languages/src/typescript/textobjects.scm @@ -18,13 +18,48 @@ (_)* @function.inside "}")) @function.around -(arrow_function +((arrow_function body: (statement_block "{" (_)* @function.inside "}")) @function.around + (#not-has-parent? @function.around variable_declarator)) -(arrow_function) @function.around +; Arrow function in variable declaration - capture the full declaration +([ + (lexical_declaration + (variable_declarator + value: (arrow_function + body: (statement_block + "{" + (_)* @function.inside + "}")))) + (variable_declaration + (variable_declarator + value: (arrow_function + body: (statement_block + "{" + (_)* @function.inside + "}")))) +]) @function.around + +; Arrow function in variable declaration - capture body as @function.inside +; (for statement blocks, the more specific pattern above captures just the contents) +([ + (lexical_declaration + (variable_declarator + value: (arrow_function + body: (_) @function.inside))) + (variable_declaration + (variable_declarator + value: (arrow_function + body: (_) @function.inside))) +]) @function.around + +; Catch-all for arrow functions in other contexts (callbacks, etc.) +((arrow_function + body: (_) @function.inside) @function.around + (#not-has-parent? @function.around variable_declarator)) (function_signature) @function.around (generator_function diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs index 02150332405c6d5ea4d5dd78f477348be968fddf..e9a2f4fc63d31f78a9a7abce8aac785b56eb1fd4 100644 --- a/crates/vim/src/object.rs +++ b/crates/vim/src/object.rs @@ -3407,4 +3407,390 @@ mod test { .assert_eq(" ˇf = (x: unknown) => {"); cx.shared_clipboard().await.assert_eq("const "); } + + #[gpui::test] + async fn test_arrow_function_text_object(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new_typescript(cx).await; + + cx.set_state( + indoc! {" + const foo = () => { + return ˇ1; + }; + "}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {" + «const foo = () => { + return 1; + };ˇ» + "}, + Mode::VisualLine, + ); + + cx.set_state( + indoc! {" + arr.map(() => { + return ˇ1; + }); + "}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {" + arr.map(«() => { + return 1; + }ˇ»); + "}, + Mode::VisualLine, + ); + + cx.set_state( + indoc! {" + const foo = () => { + return ˇ1; + }; + "}, + Mode::Normal, + ); + cx.simulate_keystrokes("v i f"); + cx.assert_state( + indoc! {" + const foo = () => { + «return 1;ˇ» + }; + "}, + Mode::Visual, + ); + + cx.set_state( + indoc! {" + (() => { + console.log(ˇ1); + })(); + "}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {" + («() => { + console.log(1); + }ˇ»)(); + "}, + Mode::VisualLine, + ); + + cx.set_state( + indoc! {" + const foo = () => { + return ˇ1; + }; + export { foo }; + "}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {" + «const foo = () => { + return 1; + };ˇ» + export { foo }; + "}, + Mode::VisualLine, + ); + + cx.set_state( + indoc! {" + let bar = () => { + return ˇ2; + }; + "}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {" + «let bar = () => { + return 2; + };ˇ» + "}, + Mode::VisualLine, + ); + + cx.set_state( + indoc! {" + var baz = () => { + return ˇ3; + }; + "}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {" + «var baz = () => { + return 3; + };ˇ» + "}, + Mode::VisualLine, + ); + + cx.set_state( + indoc! {" + const add = (a, b) => a + ˇb; + "}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {" + «const add = (a, b) => a + b;ˇ» + "}, + Mode::VisualLine, + ); + + cx.set_state( + indoc! {" + const add = ˇ(a, b) => a + b; + "}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {" + «const add = (a, b) => a + b;ˇ» + "}, + Mode::VisualLine, + ); + + cx.set_state( + indoc! {" + const add = (a, b) => a + bˇ; + "}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {" + «const add = (a, b) => a + b;ˇ» + "}, + Mode::VisualLine, + ); + + cx.set_state( + indoc! {" + const add = (a, b) =ˇ> a + b; + "}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {" + «const add = (a, b) => a + b;ˇ» + "}, + Mode::VisualLine, + ); + } + + #[gpui::test] + async fn test_arrow_function_in_jsx(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new_tsx(cx).await; + + cx.set_state( + indoc! {r#" + export const MyComponent = () => { + return ( +
+
{ + alert("Hello world!"); + console.log(ˇ"clicked"); + }}>Hello world!
+
+ ); + }; + "#}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {r#" + export const MyComponent = () => { + return ( +
+
{ + alert("Hello world!"); + console.log("clicked"); + }ˇ»}>Hello world!
+
+ ); + }; + "#}, + Mode::VisualLine, + ); + + cx.set_state( + indoc! {r#" + export const MyComponent = () => { + return ( +
+
console.log("clickˇed")}>Hello world!
+
+ ); + }; + "#}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {r#" + export const MyComponent = () => { + return ( +
+
console.log("clicked")ˇ»}>Hello world!
+
+ ); + }; + "#}, + Mode::VisualLine, + ); + + cx.set_state( + indoc! {r#" + export const MyComponent = () => { + return ( +
+
console.log("clicked")}>Hello world!
+
+ ); + }; + "#}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {r#" + export const MyComponent = () => { + return ( +
+
console.log("clicked")ˇ»}>Hello world!
+
+ ); + }; + "#}, + Mode::VisualLine, + ); + + cx.set_state( + indoc! {r#" + export const MyComponent = () => { + return ( +
+
console.log("clicked"ˇ)}>Hello world!
+
+ ); + }; + "#}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {r#" + export const MyComponent = () => { + return ( +
+
console.log("clicked")ˇ»}>Hello world!
+
+ ); + }; + "#}, + Mode::VisualLine, + ); + + cx.set_state( + indoc! {r#" + export const MyComponent = () => { + return ( +
+
console.log("clicked")}>Hello world!
+
+ ); + }; + "#}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {r#" + export const MyComponent = () => { + return ( +
+
console.log("clicked")ˇ»}>Hello world!
+
+ ); + }; + "#}, + Mode::VisualLine, + ); + + cx.set_state( + indoc! {r#" + export const MyComponent = () => { + return ( +
+
{ + console.log("cliˇcked"); + }}>Hello world!
+
+ ); + }; + "#}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {r#" + export const MyComponent = () => { + return ( +
+
{ + console.log("clicked"); + }ˇ»}>Hello world!
+
+ ); + }; + "#}, + Mode::VisualLine, + ); + + cx.set_state( + indoc! {r#" + export const MyComponent = () => { + return ( +
+
fˇoo()}>Hello world!
+
+ ); + }; + "#}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {r#" + export const MyComponent = () => { + return ( +
+
foo()ˇ»}>Hello world!
+
+ ); + }; + "#}, + Mode::VisualLine, + ); + } } diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index 3c6f237435e3924a907e059ed1a878641c287e7e..5667190bb7239ee3e534a5556d96452a7c68b1ef 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -522,12 +522,16 @@ impl Vim { selection.start = original_point.to_display_point(map) } } else { - selection.end = movement::saturating_right( - map, - original_point.to_display_point(map), - ); - if original_point.column > 0 { - selection.reversed = true + let original_display_point = + original_point.to_display_point(map); + if selection.end <= original_display_point { + selection.end = movement::saturating_right( + map, + original_display_point, + ); + if original_point.column > 0 { + selection.reversed = true + } } } }