From ff25fa24e7ac93cf7bb7a92075dd03e175c3c805 Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Thu, 6 Mar 2025 08:36:10 -0600 Subject: [PATCH] Add support for auto-closing of JSX tags (#25681) Closes #4271 Implemented by kicking of a task on the main thread at the end of `Editor::handle_input` which waits for the buffer to be re-parsed before checking if JSX tag completion possible based on the recent edits, and if it is then it spawns a task on the background thread to generate the edits to be auto-applied to the buffer Release Notes: - Added support for auto-closing of JSX tags --------- Co-authored-by: Cole Miller Co-authored-by: Max Brunsfeld Co-authored-by: Marshall Bowers Co-authored-by: Mikayla Co-authored-by: Peter Tripp --- Cargo.lock | 1 + assets/settings/default.json | 5 + crates/editor/Cargo.toml | 1 + crates/editor/src/editor.rs | 11 + crates/editor/src/editor_tests.rs | 239 ++++++++ crates/editor/src/jsx_tag_auto_close.rs | 616 ++++++++++++++++++++ crates/language/src/buffer.rs | 19 + crates/language/src/language.rs | 38 +- crates/language/src/language_settings.rs | 16 + crates/language/src/syntax_map.rs | 6 +- crates/languages/src/javascript/config.toml | 6 + crates/languages/src/lib.rs | 305 +++++----- crates/languages/src/tsx/config.toml | 6 + crates/multi_buffer/src/multi_buffer.rs | 5 + crates/rope/src/rope.rs | 82 +++ 15 files changed, 1207 insertions(+), 149 deletions(-) create mode 100644 crates/editor/src/jsx_tag_auto_close.rs diff --git a/Cargo.lock b/Cargo.lock index 710c89b277..17caa759af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4154,6 +4154,7 @@ dependencies = [ "inline_completion", "itertools 0.14.0", "language", + "languages", "linkify", "log", "lsp", diff --git a/assets/settings/default.json b/assets/settings/default.json index c501aa6d8e..cd7ac085d7 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1298,6 +1298,11 @@ // "semi": false, // "singleQuote": true }, + // Settings for auto-closing of JSX tags. + "jsx_tag_auto_close": { + // // Whether to auto-close JSX tags. + // "enabled": true + }, // LSP Specific settings. "lsp": { // Specify the LSP name as a key here. diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 08d626c25f..41991142d3 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -94,6 +94,7 @@ ctor.workspace = true env_logger.workspace = true gpui = { workspace = true, features = ["test-support"] } language = { workspace = true, features = ["test-support"] } +languages = {workspace = true, features = ["test-support"] } lsp = { workspace = true, features = ["test-support"] } multi_buffer = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 588255ff56..0841a9fac5 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -28,6 +28,7 @@ mod hover_popover; mod indent_guides; mod inlay_hint_cache; pub mod items; +mod jsx_tag_auto_close; mod linked_editing_ranges; mod lsp_ext; mod mouse_context_menu; @@ -724,6 +725,7 @@ pub struct Editor { use_autoclose: bool, use_auto_surround: bool, auto_replace_emoji_shortcode: bool, + jsx_tag_auto_close_enabled_in_any_buffer: bool, show_git_blame_gutter: bool, show_git_blame_inline: bool, show_git_blame_inline_delay_task: Option>, @@ -1410,6 +1412,7 @@ impl Editor { use_autoclose: true, use_auto_surround: true, auto_replace_emoji_shortcode: false, + jsx_tag_auto_close_enabled_in_any_buffer: false, leader_peer_id: None, remote_id: None, hover_state: Default::default(), @@ -1493,6 +1496,7 @@ impl Editor { this.end_selection(window, cx); this.scroll_manager.show_scrollbar(window, cx); + jsx_tag_auto_close::refresh_enabled_in_any_buffer(&mut this, &buffer, cx); if mode == EditorMode::Full { let should_auto_hide_scrollbars = cx.should_auto_hide_scrollbars(); @@ -3101,6 +3105,9 @@ impl Editor { drop(snapshot); self.transact(window, cx, |this, window, cx| { + let initial_buffer_versions = + jsx_tag_auto_close::construct_initial_buffer_versions_map(this, &edits, cx); + this.buffer.update(cx, |buffer, cx| { buffer.edit(edits, this.autoindent_mode.clone(), cx); }); @@ -3188,6 +3195,7 @@ impl Editor { this.trigger_completion_on_input(&text, trigger_in_words, window, cx); linked_editing_ranges::refresh_linked_ranges(this, window, cx); this.refresh_inline_completion(true, false, window, cx); + jsx_tag_auto_close::handle_from(this, initial_buffer_versions, window, cx); }); } @@ -15393,6 +15401,7 @@ impl Editor { let buffer = self.buffer.read(cx); self.registered_buffers .retain(|buffer_id, _| buffer.buffer(*buffer_id).is_some()); + jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx); cx.emit(EditorEvent::ExcerptsRemoved { ids: ids.clone() }) } multi_buffer::Event::ExcerptsEdited { @@ -15412,6 +15421,7 @@ impl Editor { } multi_buffer::Event::Reparsed(buffer_id) => { self.tasks_update_task = Some(self.refresh_runnables(window, cx)); + jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx); cx.emit(EditorEvent::Reparsed(*buffer_id)); } @@ -15420,6 +15430,7 @@ impl Editor { } multi_buffer::Event::LanguageChanged(buffer_id) => { linked_editing_ranges::refresh_linked_ranges(self, window, cx); + jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx); cx.emit(EditorEvent::Reparsed(*buffer_id)); cx.notify(); } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 51707ad846..214fda66a8 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -16779,6 +16779,245 @@ async fn test_tree_sitter_brackets_newline_insertion(cx: &mut TestAppContext) { "}); } +mod autoclose_tags { + use super::*; + use language::language_settings::JsxTagAutoCloseSettings; + use languages::language; + + async fn test_setup(cx: &mut TestAppContext) -> EditorTestContext { + init_test(cx, |settings| { + settings.defaults.jsx_tag_auto_close = Some(JsxTagAutoCloseSettings { enabled: true }); + }); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| { + let language = language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()); + + buffer.set_language(Some(language), cx) + }); + + cx + } + + macro_rules! check { + ($name:ident, $initial:literal + $input:literal => $expected:expr) => { + #[gpui::test] + async fn $name(cx: &mut TestAppContext) { + let mut cx = test_setup(cx).await; + cx.set_state($initial); + cx.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + editor.handle_input($input, window, cx); + }); + cx.run_until_parked(); + cx.assert_editor_state($expected); + } + }; + } + + check!( + test_basic, + "" => "
ˇ
" + ); + + check!( + test_basic_nested, + "
" + ">" => "
ˇ
" + ); + + check!( + test_basic_ignore_already_closed, + "
" + ">" => "
ˇ
" + ); + + check!( + test_doesnt_autoclose_closing_tag, + "" => "
ˇ" + ); + + check!( + test_jsx_attr, + "
}ˇ" + ">" => "
}>ˇ
" + ); + + check!( + test_ignores_closing_tags_in_expr_block, + "
}
" + ">" => "
ˇ
{
}
" + ); + + check!( + test_doesnt_autoclose_on_gt_in_expr, + "
" => "
ˇ" + ); + + check!( + test_ignores_closing_tags_with_different_tag_names, + "
" + ">" => "
ˇ
" + ); + + check!( + test_autocloses_in_jsx_expression, + "
{" + ">" => "
{
ˇ
}
" + ); + + check!( + test_doesnt_autoclose_already_closed_in_jsx_expression, + "
{}
" + ">" => "
{
ˇ
}
" + ); + + check!( + test_autocloses_fragment, + "<ˇ" + ">" => "<>ˇ" + ); + + check!( + test_does_not_include_type_argument_in_autoclose_tag_name, + " attr={boolean_value}ˇ" + ">" => " attr={boolean_value}>ˇ" + ); + + check!( + test_does_not_autoclose_doctype, + "" => "ˇ" + ); + + check!( + test_does_not_autoclose_comment, + "ˇ" + ); + + check!( + test_multi_cursor_autoclose_same_tag, + r#" + " => + r#" +
ˇ
+
ˇ
+ "# + ); + + check!( + test_multi_cursor_autoclose_different_tags, + r#" + " => + r#" +
ˇ
+ ˇ + "# + ); + + check!( + test_multi_cursor_autoclose_some_dont_autoclose_others, + r#" + + ˇ + ˇ + "# + + ">" => + r#" +
ˇ
+
ˇ + ˇ + ˇ + ˇ + >ˇ + >ˇ + "# + ); + + check!( + test_doesnt_mess_up_trailing_text, + "" => "
ˇ
foobar" + ); + + #[gpui::test] + async fn test_multibuffer(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.jsx_tag_auto_close = Some(JsxTagAutoCloseSettings { enabled: true }); + }); + + let buffer_a = cx.new(|cx| { + let mut buf = language::Buffer::local("", window, cx); + }); + cx.run_until_parked(); + + cx.assert_editor_state("
ˇ
\n
ˇ
\nˇ"); + } +} + fn empty_range(row: usize, column: usize) -> Range { let point = DisplayPoint::new(DisplayRow(row as u32), column as u32); point..point diff --git a/crates/editor/src/jsx_tag_auto_close.rs b/crates/editor/src/jsx_tag_auto_close.rs new file mode 100644 index 0000000000..990e94630f --- /dev/null +++ b/crates/editor/src/jsx_tag_auto_close.rs @@ -0,0 +1,616 @@ +use anyhow::{anyhow, Context as _, Result}; +use collections::HashMap; +use gpui::{Context, Entity, Window}; +use multi_buffer::{MultiBuffer, ToOffset}; +use std::ops::Range; +use util::ResultExt as _; + +use language::{BufferSnapshot, JsxTagAutoCloseConfig, Node}; +use text::{Anchor, OffsetRangeExt as _}; + +use crate::Editor; + +pub struct JsxTagCompletionState { + edit_index: usize, + open_tag_range: Range, +} + +/// Index of the named child within an open or close tag +/// that corresponds to the tag name +/// Note that this is not configurable, i.e. we assume the first +/// named child of a tag node is the tag name +const TS_NODE_TAG_NAME_CHILD_INDEX: usize = 0; + +/// Maximum number of parent elements to walk back when checking if an open tag +/// is already closed. +/// +/// See the comment in `generate_auto_close_edits` for more details +const ALREADY_CLOSED_PARENT_ELEMENT_WALK_BACK_LIMIT: usize = 2; + +pub(crate) fn should_auto_close( + buffer: &BufferSnapshot, + edited_ranges: &[Range], + config: &JsxTagAutoCloseConfig, +) -> Option> { + let mut to_auto_edit = vec![]; + for (index, edited_range) in edited_ranges.iter().enumerate() { + let text = buffer + .text_for_range(edited_range.clone()) + .collect::(); + if !text.ends_with(">") { + continue; + } + let Some(layer) = buffer.smallest_syntax_layer_containing(edited_range.clone()) else { + continue; + }; + let Some(node) = layer + .node() + .named_descendant_for_byte_range(edited_range.start, edited_range.end) + else { + continue; + }; + let mut jsx_open_tag_node = node; + if node.grammar_name() != config.open_tag_node_name { + if let Some(parent) = node.parent() { + if parent.grammar_name() == config.open_tag_node_name { + jsx_open_tag_node = parent; + } + } + } + if jsx_open_tag_node.grammar_name() != config.open_tag_node_name { + continue; + } + + let first_two_chars: Option<[char; 2]> = { + let mut chars = buffer + .text_for_range(jsx_open_tag_node.byte_range()) + .flat_map(|chunk| chunk.chars()); + if let (Some(c1), Some(c2)) = (chars.next(), chars.next()) { + Some([c1, c2]) + } else { + None + } + }; + if let Some(chars) = first_two_chars { + if chars[0] != '<' { + continue; + } + if chars[1] == '!' || chars[1] == '/' { + continue; + } + } + + to_auto_edit.push(JsxTagCompletionState { + edit_index: index, + open_tag_range: jsx_open_tag_node.byte_range(), + }); + } + if to_auto_edit.is_empty() { + return None; + } else { + return Some(to_auto_edit); + } +} + +pub(crate) fn generate_auto_close_edits( + buffer: &BufferSnapshot, + ranges: &[Range], + config: &JsxTagAutoCloseConfig, + state: Vec, +) -> Result, String)>> { + let mut edits = Vec::with_capacity(state.len()); + for auto_edit in state { + let edited_range = ranges[auto_edit.edit_index].clone(); + let Some(layer) = buffer.smallest_syntax_layer_containing(edited_range.clone()) else { + continue; + }; + let layer_root_node = layer.node(); + let Some(open_tag) = layer_root_node.descendant_for_byte_range( + auto_edit.open_tag_range.start, + auto_edit.open_tag_range.end, + ) else { + continue; + }; + assert!(open_tag.kind() == config.open_tag_node_name); + let tag_name = open_tag + .named_child(TS_NODE_TAG_NAME_CHILD_INDEX) + .filter(|node| node.kind() == config.tag_name_node_name) + .map_or("".to_string(), |node| { + buffer.text_for_range(node.byte_range()).collect::() + }); + + /* + * Naive check to see if the tag is already closed + * Essentially all we do is count the number of open and close tags + * with the same tag name as the open tag just entered by the user + * The search is limited to some scope determined by + * `ALREADY_CLOSED_PARENT_ELEMENT_WALK_BACK_LIMIT` + * + * The limit is preferable to walking up the tree until we find a non-tag node, + * and then checking the entire tree, as this is unnecessarily expensive, and + * risks false positives + * eg. a `
` tag without a corresponding opening tag exists 25 lines away + * and the user typed in `
`, intuitively we still want to auto-close it because + * the other `
` tag is almost certainly not supposed to be the closing tag for the + * current element + * + * We have to walk up the tree some amount because tree-sitters error correction is not + * designed to handle this case, and usually does not represent the tree structure + * in the way we might expect, + * + * We half to walk up the tree until we hit an element with a different open tag name (`doing_deep_search == true`) + * because tree-sitter may pair the new open tag with the root of the tree's closing tag leaving the + * root's opening tag unclosed. + * e.g + * ``` + *
+ *
|cursor here| + *
+ * ``` + * in Astro/vue/svelte tree-sitter represented the tree as + * ( + * (jsx_element + * (jsx_opening_element + * "
") + * ) + * (jsx_element + * (jsx_opening_element + * "
") // <- cursor is here + * (jsx_closing_element + * "
") + * ) + * ) + * so if we only walked to the first `jsx_element` node, + * we would mistakenly identify the div entered by the + * user as already being closed, despite this clearly + * being false + * + * The errors with the tree-sitter tree caused by error correction, + * are also why the naive algorithm was chosen, as the alternative + * approach would be to maintain or construct a full parse tree (like tree-sitter) + * that better represents errors in a way that we can simply check + * the enclosing scope of the entered tag for a closing tag + * This is far more complex and expensive, and was deemed impractical + * given that the naive algorithm is sufficient in the majority of cases. + */ + { + let tag_node_name_equals = |node: &Node, tag_name_node_name: &str, name: &str| { + let is_empty = name.len() == 0; + if let Some(node_name) = node.named_child(TS_NODE_TAG_NAME_CHILD_INDEX) { + if node_name.kind() != tag_name_node_name { + return is_empty; + } + let range = node_name.byte_range(); + return buffer.text_for_range(range).equals_str(name); + } + return is_empty; + }; + + let tree_root_node = { + let mut ancestors = Vec::with_capacity( + // estimate of max, not based on any data, + // but trying to avoid excessive reallocation + 16, + ); + ancestors.push(layer_root_node); + let mut cur = layer_root_node; + // walk down the tree until we hit the open tag + // note: this is what node.parent() does internally + while let Some(descendant) = cur.child_with_descendant(open_tag) { + if descendant == open_tag { + break; + } + ancestors.push(descendant); + cur = descendant; + } + + assert!(ancestors.len() > 0); + + let mut tree_root_node = open_tag; + + let mut parent_element_node_count = 0; + let mut doing_deep_search = false; + + for &ancestor in ancestors.iter().rev() { + tree_root_node = ancestor; + let is_element = ancestor.kind() == config.jsx_element_node_name; + let is_error = ancestor.is_error(); + if is_error || !is_element { + break; + } + if is_element { + let is_first = parent_element_node_count == 0; + if !is_first { + let has_open_tag_with_same_tag_name = ancestor + .named_child(0) + .filter(|n| n.kind() == config.open_tag_node_name) + .map_or(false, |element_open_tag_node| { + tag_node_name_equals( + &element_open_tag_node, + &config.tag_name_node_name, + &tag_name, + ) + }); + if has_open_tag_with_same_tag_name { + doing_deep_search = true; + } else if doing_deep_search { + break; + } + } + parent_element_node_count += 1; + if !doing_deep_search + && parent_element_node_count + >= ALREADY_CLOSED_PARENT_ELEMENT_WALK_BACK_LIMIT + { + break; + } + } + } + tree_root_node + }; + + let mut unclosed_open_tag_count: i32 = 0; + + let mut cursor = layer_root_node.walk(); + + let mut stack = Vec::with_capacity(tree_root_node.descendant_count()); + stack.extend(tree_root_node.children(&mut cursor)); + + let mut has_erroneous_close_tag = false; + let mut erroneous_close_tag_node_name = ""; + let mut erroneous_close_tag_name_node_name = ""; + if let Some(name) = config.erroneous_close_tag_node_name.as_deref() { + has_erroneous_close_tag = true; + erroneous_close_tag_node_name = name; + erroneous_close_tag_name_node_name = config + .erroneous_close_tag_name_node_name + .as_deref() + .unwrap_or(&config.tag_name_node_name); + } + + let is_after_open_tag = |node: &Node| { + return node.start_byte() < open_tag.start_byte() + && node.end_byte() < open_tag.start_byte(); + }; + + // perf: use cursor for more efficient traversal + // if child -> go to child + // else if next sibling -> go to next sibling + // else -> go to parent + // if parent == tree_root_node -> break + while let Some(node) = stack.pop() { + let kind = node.kind(); + if kind == config.open_tag_node_name { + if tag_node_name_equals(&node, &config.tag_name_node_name, &tag_name) { + unclosed_open_tag_count += 1; + } + } else if kind == config.close_tag_node_name { + if tag_node_name_equals(&node, &config.tag_name_node_name, &tag_name) { + unclosed_open_tag_count -= 1; + } + } else if has_erroneous_close_tag && kind == erroneous_close_tag_node_name { + if tag_node_name_equals(&node, erroneous_close_tag_name_node_name, &tag_name) { + if !is_after_open_tag(&node) { + unclosed_open_tag_count -= 1; + } + } + } else if kind == config.jsx_element_node_name { + // perf: filter only open,close,element,erroneous nodes + stack.extend(node.children(&mut cursor)); + } + } + + if unclosed_open_tag_count <= 0 { + // skip if already closed + continue; + } + } + let edit_anchor = buffer.anchor_after(edited_range.end); + let edit_range = edit_anchor..edit_anchor; + edits.push((edit_range, format!("", tag_name))); + } + return Ok(edits); +} + +pub(crate) fn refresh_enabled_in_any_buffer( + editor: &mut Editor, + multi_buffer: &Entity, + cx: &Context, +) { + editor.jsx_tag_auto_close_enabled_in_any_buffer = { + let multi_buffer = multi_buffer.read(cx); + let mut found_enabled = false; + multi_buffer.for_each_buffer(|buffer| { + let buffer = buffer.read(cx); + let snapshot = buffer.snapshot(); + for syntax_layer in snapshot.syntax_layers() { + let language = syntax_layer.language; + if language.config().jsx_tag_auto_close.is_none() { + continue; + } + let language_settings = language::language_settings::language_settings( + Some(language.name()), + snapshot.file(), + cx, + ); + if language_settings.jsx_tag_auto_close.enabled { + found_enabled = true; + } + } + }); + + found_enabled + }; +} + +pub(crate) type InitialBufferVersionsMap = HashMap; + +pub(crate) fn construct_initial_buffer_versions_map< + D: ToOffset + Copy, + _S: Into>, +>( + editor: &Editor, + edits: &[(Range, _S)], + cx: &Context, +) -> InitialBufferVersionsMap { + let mut initial_buffer_versions = InitialBufferVersionsMap::default(); + + if !editor.jsx_tag_auto_close_enabled_in_any_buffer { + return initial_buffer_versions; + } + + for (edit_range, _) in edits { + let edit_range_buffer = editor + .buffer() + .read(cx) + .excerpt_containing(edit_range.end, cx) + .map(|e| e.1); + if let Some(buffer) = edit_range_buffer { + let (buffer_id, buffer_version) = + buffer.read_with(cx, |buffer, _| (buffer.remote_id(), buffer.version.clone())); + initial_buffer_versions.insert(buffer_id, buffer_version); + } + } + return initial_buffer_versions; +} + +pub(crate) fn handle_from( + editor: &Editor, + initial_buffer_versions: InitialBufferVersionsMap, + window: &mut Window, + cx: &mut Context, +) { + if !editor.jsx_tag_auto_close_enabled_in_any_buffer { + return; + } + + struct JsxAutoCloseEditContext { + buffer: Entity, + config: language::JsxTagAutoCloseConfig, + edits: Vec>, + } + + let mut edit_contexts = + HashMap::<(language::BufferId, language::LanguageId), JsxAutoCloseEditContext>::default(); + + for (buffer_id, buffer_version_initial) in initial_buffer_versions { + let Some(buffer) = editor.buffer.read(cx).buffer(buffer_id) else { + continue; + }; + let snapshot = buffer.read(cx).snapshot(); + for edit in buffer.read(cx).edits_since(&buffer_version_initial) { + let Some(language) = snapshot.language_at(edit.new.end) else { + continue; + }; + + let Some(config) = language.config().jsx_tag_auto_close.as_ref() else { + continue; + }; + + let language_settings = snapshot.settings_at(edit.new.end, cx); + if !language_settings.jsx_tag_auto_close.enabled { + continue; + } + + edit_contexts + .entry((snapshot.remote_id(), language.id())) + .or_insert_with(|| JsxAutoCloseEditContext { + buffer: buffer.clone(), + config: config.clone(), + edits: vec![], + }) + .edits + .push(edit.new); + } + } + + for ((buffer_id, _), auto_close_context) in edit_contexts { + let JsxAutoCloseEditContext { + buffer, + config: jsx_tag_auto_close_config, + edits: edited_ranges, + } = auto_close_context; + + let (buffer_version_initial, mut buffer_parse_status_rx) = + buffer.read_with(cx, |buffer, _| (buffer.version(), buffer.parse_status())); + + cx.spawn_in(window, |this, mut cx| async move { + let Some(buffer_parse_status) = buffer_parse_status_rx.recv().await.ok() else { + return Some(()); + }; + if buffer_parse_status == language::ParseStatus::Parsing { + let Some(language::ParseStatus::Idle) = buffer_parse_status_rx.recv().await.ok() + else { + return Some(()); + }; + } + + let buffer_snapshot = buffer.read_with(&cx, |buf, _| buf.snapshot()).ok()?; + + let Some(edit_behavior_state) = + should_auto_close(&buffer_snapshot, &edited_ranges, &jsx_tag_auto_close_config) + else { + return Some(()); + }; + + let ensure_no_edits_since_start = || -> Option<()> { + //
wef,wefwef + let has_edits_since_start = this + .read_with(&cx, |this, cx| { + this.buffer.read_with(cx, |buffer, cx| { + buffer.buffer(buffer_id).map_or(true, |buffer| { + buffer.read_with(cx, |buffer, _| { + buffer.has_edits_since(&buffer_version_initial) + }) + }) + }) + }) + .ok()?; + + if has_edits_since_start { + Err(anyhow!( + "Auto-close Operation Failed - Buffer has edits since start" + )) + .log_err()?; + } + + Some(()) + }; + + ensure_no_edits_since_start()?; + + let edits = cx + .background_executor() + .spawn({ + let buffer_snapshot = buffer_snapshot.clone(); + async move { + generate_auto_close_edits( + &buffer_snapshot, + &edited_ranges, + &jsx_tag_auto_close_config, + edit_behavior_state, + ) + } + }) + .await; + + let edits = edits + .context("Auto-close Operation Failed - Failed to compute edits") + .log_err()?; + + if edits.is_empty() { + return Some(()); + } + + // check again after awaiting background task before applying edits + ensure_no_edits_since_start()?; + + let multi_buffer_snapshot = this + .read_with(&cx, |this, cx| { + this.buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx)) + }) + .ok()?; + + let mut base_selections = Vec::new(); + let mut buffer_selection_map = HashMap::default(); + + { + let selections = this + .read_with(&cx, |this, _| this.selections.disjoint_anchors().clone()) + .ok()?; + for selection in selections.iter() { + let Some(selection_buffer_offset_head) = + multi_buffer_snapshot.point_to_buffer_offset(selection.head()) + else { + base_selections.push(selection.clone()); + continue; + }; + let Some(selection_buffer_offset_tail) = + multi_buffer_snapshot.point_to_buffer_offset(selection.tail()) + else { + base_selections.push(selection.clone()); + continue; + }; + + let is_entirely_in_buffer = selection_buffer_offset_head.0.remote_id() + == buffer_id + && selection_buffer_offset_tail.0.remote_id() == buffer_id; + if !is_entirely_in_buffer { + base_selections.push(selection.clone()); + continue; + } + + let selection_buffer_offset_head = selection_buffer_offset_head.1; + let selection_buffer_offset_tail = selection_buffer_offset_tail.1; + buffer_selection_map.insert( + (selection_buffer_offset_head, selection_buffer_offset_tail), + (selection.clone(), None), + ); + } + } + + let mut any_selections_need_update = false; + for edit in &edits { + let edit_range_offset = edit.0.to_offset(&buffer_snapshot); + if edit_range_offset.start != edit_range_offset.end { + continue; + } + if let Some(selection) = + buffer_selection_map.get_mut(&(edit_range_offset.start, edit_range_offset.end)) + { + if selection.0.head().bias() != text::Bias::Right + || selection.0.tail().bias() != text::Bias::Right + { + continue; + } + if selection.1.is_none() { + any_selections_need_update = true; + selection.1 = Some( + selection + .0 + .clone() + .map(|anchor| multi_buffer_snapshot.anchor_before(anchor)), + ); + } + } + } + + buffer + .update(&mut cx, |buffer, cx| { + buffer.edit(edits, None, cx); + }) + .ok()?; + + if any_selections_need_update { + let multi_buffer_snapshot = this + .read_with(&cx, |this, cx| { + this.buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx)) + }) + .ok()?; + + base_selections.extend(buffer_selection_map.values().map(|selection| { + match &selection.1 { + Some(left_biased_selection) => left_biased_selection.clone(), + None => selection.0.clone(), + } + })); + + let base_selections = base_selections + .into_iter() + .map(|selection| { + selection.map(|anchor| anchor.to_offset(&multi_buffer_snapshot)) + }) + .collect::>(); + this.update_in(&mut cx, |this, window, cx| { + this.change_selections_inner(None, false, window, cx, |s| { + s.select(base_selections); + }); + }) + .ok()?; + } + + Some(()) + }) + .detach(); + } +} diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 9c9a319a57..9c1279e5fe 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -3080,6 +3080,25 @@ impl BufferSnapshot { .last() } + pub fn smallest_syntax_layer_containing( + &self, + range: Range, + ) -> Option { + let range = range.to_offset(self); + return self + .syntax + .layers_for_range(range, &self.text, false) + .max_by(|a, b| { + if a.depth != b.depth { + a.depth.cmp(&b.depth) + } else if a.offset.0 != b.offset.0 { + a.offset.0.cmp(&b.offset.0) + } else { + a.node().end_byte().cmp(&b.node().end_byte()).reverse() + } + }); + } + /// Returns the main [`Language`]. pub fn language(&self) -> Option<&Arc> { self.language.as_ref() diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 7bb545dc5a..ea2b23996e 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -680,6 +680,9 @@ pub struct LanguageConfig { /// languages, but should not appear to the user as a distinct language. #[serde(default)] pub hidden: bool, + /// If configured, this language contains JSX style tags, and should support auto-closing of those tags. + #[serde(default)] + pub jsx_tag_auto_close: Option, } #[derive(Clone, Debug, Serialize, Deserialize, Default, JsonSchema)] @@ -697,6 +700,34 @@ pub struct LanguageMatcher { pub first_line_pattern: Option, } +/// The configuration for JSX tag auto-closing. +#[derive(Clone, Deserialize, JsonSchema)] +pub struct JsxTagAutoCloseConfig { + /// The name of the node for a opening tag + pub open_tag_node_name: String, + /// The name of the node for an closing tag + pub close_tag_node_name: String, + /// The name of the node for a complete element with children for open and close tags + pub jsx_element_node_name: String, + /// The name of the node found within both opening and closing + /// tags that describes the tag name + pub tag_name_node_name: String, + /// Some grammars are smart enough to detect a closing tag + /// that is not valid i.e. doesn't match it's corresponding + /// opening tag or does not have a corresponding opening tag + /// This should be set to the name of the node for invalid + /// closing tags if the grammar contains such a node, otherwise + /// detecting already closed tags will not work properly + #[serde(default)] + pub erroneous_close_tag_node_name: Option, + /// See above for erroneous_close_tag_node_name for details + /// This should be set if the node used for the tag name + /// within erroneous closing tags is different from the + /// normal tag name node name + #[serde(default)] + pub erroneous_close_tag_name_node_name: Option, +} + /// Represents a language for the given range. Some languages (e.g. HTML) /// interleave several languages together, thus a single buffer might actually contain /// several nested scopes. @@ -767,6 +798,7 @@ impl Default for LanguageConfig { soft_wrap: None, prettier_parser_name: None, hidden: false, + jsx_tag_auto_close: None, } } } @@ -888,7 +920,7 @@ pub struct BracketPair { } #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] -pub(crate) struct LanguageId(usize); +pub struct LanguageId(usize); impl LanguageId { pub(crate) fn new() -> Self { @@ -1056,6 +1088,10 @@ impl Language { Self::new_with_id(LanguageId::new(), config, ts_language) } + pub fn id(&self) -> LanguageId { + self.id + } + fn new_with_id( id: LanguageId, config: LanguageConfig, diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index a42ff617c7..be5b2ae6a6 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -100,6 +100,8 @@ pub struct LanguageSettings { pub formatter: SelectedFormatter, /// Zed's Prettier integration settings. pub prettier: PrettierSettings, + /// Whether to automatically close JSX tags. + pub jsx_tag_auto_close: JsxTagAutoCloseSettings, /// Whether to use language servers to provide code intelligence. pub enable_language_server: bool, /// The list of language servers to use (or disable) for this language. @@ -374,6 +376,9 @@ pub struct LanguageSettingsContent { /// Default: off #[serde(default)] pub prettier: Option, + /// Whether to automatically close JSX tags. + #[serde(default)] + pub jsx_tag_auto_close: Option, /// Whether to use language servers to provide code intelligence. /// /// Default: true @@ -1335,6 +1340,10 @@ fn merge_settings(settings: &mut LanguageSettings, src: &LanguageSettingsContent ); merge(&mut settings.formatter, src.formatter.clone()); merge(&mut settings.prettier, src.prettier.clone()); + merge( + &mut settings.jsx_tag_auto_close, + src.jsx_tag_auto_close.clone(), + ); merge(&mut settings.format_on_save, src.format_on_save.clone()); merge( &mut settings.remove_trailing_whitespace_on_save, @@ -1398,6 +1407,13 @@ pub struct PrettierSettings { pub options: HashMap, } +#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct JsxTagAutoCloseSettings { + /// Enables or disables auto-closing of JSX tags. + #[serde(default)] + pub enabled: bool, +} + #[cfg(test)] mod tests { use gpui::TestAppContext; diff --git a/crates/language/src/syntax_map.rs b/crates/language/src/syntax_map.rs index 61fcf0fa94..bbdf2cf2c8 100644 --- a/crates/language/src/syntax_map.rs +++ b/crates/language/src/syntax_map.rs @@ -121,9 +121,9 @@ impl SyntaxLayerContent { pub struct SyntaxLayer<'a> { /// The language for this layer. pub language: &'a Arc, - depth: usize, + pub(crate) depth: usize, tree: &'a Tree, - offset: (usize, tree_sitter::Point), + pub(crate) offset: (usize, tree_sitter::Point), } /// A layer of syntax highlighting. Like [SyntaxLayer], but holding @@ -133,7 +133,7 @@ pub struct OwnedSyntaxLayer { /// The language for this layer. pub language: Arc, tree: tree_sitter::Tree, - offset: (usize, tree_sitter::Point), + pub offset: (usize, tree_sitter::Point), } #[derive(Debug, Clone)] diff --git a/crates/languages/src/javascript/config.toml b/crates/languages/src/javascript/config.toml index b770a28071..9f39ba14aa 100644 --- a/crates/languages/src/javascript/config.toml +++ b/crates/languages/src/javascript/config.toml @@ -20,6 +20,12 @@ tab_size = 2 scope_opt_in_language_servers = ["tailwindcss-language-server", "emmet-language-server"] prettier_parser_name = "babel" +[jsx_tag_auto_close] +open_tag_node_name = "jsx_opening_element" +close_tag_node_name = "jsx_closing_element" +jsx_element_node_name = "jsx_element" +tag_name_node_name = "identifier" + [overrides.element] line_comments = { remove = true } block_comment = ["{/* ", " */}"] diff --git a/crates/languages/src/lib.rs b/crates/languages/src/lib.rs index 23a5dcc0b4..566d62969f 100644 --- a/crates/languages/src/lib.rs +++ b/crates/languages/src/lib.rs @@ -11,7 +11,7 @@ use std::{str, sync::Arc}; use typescript::typescript_task_context; use util::{asset_str, ResultExt}; -use crate::{bash::bash_task_context, go::GoContextProvider, rust::RustContextProvider}; +use crate::{bash::bash_task_context, rust::RustContextProvider}; mod bash; mod c; @@ -74,177 +74,191 @@ pub fn init(languages: Arc, node_runtime: NodeRuntime, cx: &mu ("gitcommit", tree_sitter_gitcommit::LANGUAGE), ]); - macro_rules! language { - ($name:literal) => { - let config = load_config($name); - languages.register_language( - config.name.clone(), - config.grammar.clone(), - config.matcher.clone(), - config.hidden, - Arc::new(move || { - Ok(LoadedLanguage { - config: config.clone(), - queries: load_queries($name), - context_provider: None, - toolchain_provider: None, - }) - }), - ); + // Following are a series of helper macros for registering languages. + // Macros are used instead of a function or for loop in order to avoid + // code duplication and improve readability as the types get quite verbose + // to type out in some cases. + // Additionally, the `provider` fields in LoadedLanguage + // would have be `Copy` if we were to use a function or for-loop to register the languages + // due to the fact that we pass an `Arc` to `languages.register_language` + // that loads and initializes the language lazily. + // We avoid this entirely by using a Macro + + macro_rules! context_provider { + ($name:expr) => { + Some(Arc::new($name) as Arc) }; - ($name:literal, $adapters:expr) => { - let config = load_config($name); - // typeck helper - let adapters: Vec> = $adapters; - for adapter in adapters { - languages.register_lsp_adapter(config.name.clone(), adapter); - } - languages.register_language( - config.name.clone(), - config.grammar.clone(), - config.matcher.clone(), - config.hidden, - Arc::new(move || { - Ok(LoadedLanguage { - config: config.clone(), - queries: load_queries($name), - context_provider: None, - toolchain_provider: None, - }) - }), - ); - }; - ($name:literal, $adapters:expr, $context_provider:expr) => { - let config = load_config($name); - // typeck helper - let adapters: Vec> = $adapters; - for adapter in adapters { - languages.register_lsp_adapter(config.name.clone(), adapter); - } - languages.register_language( - config.name.clone(), - config.grammar.clone(), - config.matcher.clone(), - config.hidden, - Arc::new(move || { - Ok(LoadedLanguage { - config: config.clone(), - queries: load_queries($name), - context_provider: Some(Arc::new($context_provider)), - toolchain_provider: None, - }) - }), - ); - }; - ($name:literal, $adapters:expr, $context_provider:expr, $toolchain_provider:expr) => { - let config = load_config($name); - // typeck helper - let adapters: Vec> = $adapters; - for adapter in adapters { - languages.register_lsp_adapter(config.name.clone(), adapter); - } - languages.register_language( - config.name.clone(), - config.grammar.clone(), - config.matcher.clone(), - config.hidden, - Arc::new(move || { - Ok(LoadedLanguage { - config: config.clone(), - queries: load_queries($name), - context_provider: Some(Arc::new($context_provider)), - toolchain_provider: Some($toolchain_provider), - }) - }), - ); + () => { + None }; } - language!("bash", Vec::new(), bash_task_context()); - language!("c", vec![Arc::new(c::CLspAdapter) as Arc]); - language!("cpp", vec![Arc::new(c::CLspAdapter)]); - language!( - "css", - vec![Arc::new(css::CssLspAdapter::new(node_runtime.clone())),] - ); - language!("diff"); - language!("go", vec![Arc::new(go::GoLspAdapter)], GoContextProvider); - language!("gomod", vec![Arc::new(go::GoLspAdapter)], GoContextProvider); - language!( - "gowork", - vec![Arc::new(go::GoLspAdapter)], - GoContextProvider + + macro_rules! toolchain_provider { + ($name:expr) => { + Some(Arc::new($name) as Arc) + }; + () => { + None + }; + } + + macro_rules! adapters { + ($($item:expr),+ $(,)?) => { + vec![ + $(Arc::new($item) as Arc,)* + ] + }; + () => { + vec![] + }; + } + + macro_rules! register_language { + ($name:expr, adapters => $adapters:expr, context => $context:expr, toolchain => $toolchain:expr) => { + let config = load_config($name); + for adapter in $adapters { + languages.register_lsp_adapter(config.name.clone(), adapter); + } + languages.register_language( + config.name.clone(), + config.grammar.clone(), + config.matcher.clone(), + config.hidden, + Arc::new(move || { + Ok(LoadedLanguage { + config: config.clone(), + queries: load_queries($name), + context_provider: $context, + toolchain_provider: $toolchain, + }) + }), + ); + }; + ($name:expr) => { + register_language!($name, adapters => adapters![], context => context_provider!(), toolchain => toolchain_provider!()) + }; + ($name:expr, adapters => $adapters:expr, context => $context:expr, toolchain => $toolchain:expr) => { + register_language!($name, adapters => $adapters, context => $context, toolchain => $toolchain) + }; + ($name:expr, adapters => $adapters:expr, context => $context:expr) => { + register_language!($name, adapters => $adapters, context => $context, toolchain => toolchain_provider!()) + }; + ($name:expr, adapters => $adapters:expr) => { + register_language!($name, adapters => $adapters, context => context_provider!(), toolchain => toolchain_provider!()) + }; + } + + register_language!( + "bash", + adapters => adapters![], + context => context_provider!(bash_task_context()), + toolchain => toolchain_provider!() ); - language!( + register_language!( + "c", + adapters => adapters![c::CLspAdapter] + ); + register_language!( + "cpp", + adapters => adapters![c::CLspAdapter] + ); + + register_language!( + "css", + adapters => adapters![css::CssLspAdapter::new(node_runtime.clone())] + ); + + register_language!("diff"); + + register_language!( + "go", + adapters => adapters![go::GoLspAdapter], + context => context_provider!(go::GoContextProvider) + ); + register_language!( + "gomod", + adapters => adapters![go::GoLspAdapter], + context => context_provider!(go::GoContextProvider) + ); + register_language!( + "gowork", + adapters => adapters![go::GoLspAdapter], + context => context_provider!(go::GoContextProvider) + ); + + register_language!( "json", - vec![ - Arc::new(json::JsonLspAdapter::new( - node_runtime.clone(), - languages.clone(), - )), - Arc::new(json::NodeVersionAdapter) + adapters => adapters![ + json::JsonLspAdapter::new(node_runtime.clone(), languages.clone(),), + json::NodeVersionAdapter, ], - json_task_context() + context => context_provider!(json_task_context()) ); - language!( + register_language!( "jsonc", - vec![Arc::new(json::JsonLspAdapter::new( - node_runtime.clone(), - languages.clone(), - ))], - json_task_context() + adapters => adapters![ + json::JsonLspAdapter::new(node_runtime.clone(), languages.clone(),), + ], + context => context_provider!(json_task_context()) ); - language!("markdown"); - language!("markdown-inline"); - language!( + + register_language!("markdown"); + register_language!("markdown-inline"); + + register_language!( "python", - vec![ - Arc::new(python::PythonLspAdapter::new(node_runtime.clone(),)), - Arc::new(python::PyLspAdapter::new()) + adapters => adapters![ + python::PythonLspAdapter::new(node_runtime.clone()), + python::PyLspAdapter::new() ], - PythonContextProvider, - Arc::new(PythonToolchainProvider::default()) as Arc + context => context_provider!(PythonContextProvider), + toolchain => toolchain_provider!(PythonToolchainProvider::default()) ); - language!( + register_language!( "rust", - vec![Arc::new(rust::RustLspAdapter)], - RustContextProvider + adapters => adapters![rust::RustLspAdapter], + context => context_provider!(RustContextProvider) ); - language!( + register_language!( "tsx", - vec![ - Arc::new(typescript::TypeScriptLspAdapter::new(node_runtime.clone())), - Arc::new(vtsls::VtslsLspAdapter::new(node_runtime.clone())) + adapters => adapters![ + typescript::TypeScriptLspAdapter::new(node_runtime.clone()), + vtsls::VtslsLspAdapter::new(node_runtime.clone()), ], - typescript_task_context() + context => context_provider!(typescript_task_context()), + toolchain => toolchain_provider!() ); - language!( + register_language!( "typescript", - vec![ - Arc::new(typescript::TypeScriptLspAdapter::new(node_runtime.clone())), - Arc::new(vtsls::VtslsLspAdapter::new(node_runtime.clone())) + adapters => adapters![ + typescript::TypeScriptLspAdapter::new(node_runtime.clone()), + vtsls::VtslsLspAdapter::new(node_runtime.clone()), ], - typescript_task_context() + context => context_provider!(typescript_task_context()) ); - language!( + register_language!( "javascript", - vec![ - Arc::new(typescript::TypeScriptLspAdapter::new(node_runtime.clone())), - Arc::new(vtsls::VtslsLspAdapter::new(node_runtime.clone())) + adapters => adapters![ + typescript::TypeScriptLspAdapter::new(node_runtime.clone()), + vtsls::VtslsLspAdapter::new(node_runtime.clone()), ], - typescript_task_context() + context => context_provider!(typescript_task_context()) ); - language!( + register_language!( "jsdoc", - vec![ - Arc::new(typescript::TypeScriptLspAdapter::new(node_runtime.clone(),)), - Arc::new(vtsls::VtslsLspAdapter::new(node_runtime.clone())) + adapters => adapters![ + typescript::TypeScriptLspAdapter::new(node_runtime.clone()), + vtsls::VtslsLspAdapter::new(node_runtime.clone()), ] ); - language!("regex"); - language!( - "yaml", - vec![Arc::new(yaml::YamlLspAdapter::new(node_runtime.clone()))] + + register_language!("regex"); + + register_language!("yaml", + adapters => adapters![ + yaml::YamlLspAdapter::new(node_runtime.clone()), + ] ); // Register globally available language servers. @@ -366,6 +380,7 @@ fn load_config(name: &str) -> LanguageConfig { config = LanguageConfig { name: config.name, matcher: config.matcher, + jsx_tag_auto_close: config.jsx_tag_auto_close, ..Default::default() } } diff --git a/crates/languages/src/tsx/config.toml b/crates/languages/src/tsx/config.toml index 3cd377ce0b..99d17c5af6 100644 --- a/crates/languages/src/tsx/config.toml +++ b/crates/languages/src/tsx/config.toml @@ -18,6 +18,12 @@ scope_opt_in_language_servers = ["tailwindcss-language-server", "emmet-language- prettier_parser_name = "typescript" tab_size = 2 +[jsx_tag_auto_close] +open_tag_node_name = "jsx_opening_element" +close_tag_node_name = "jsx_closing_element" +jsx_element_node_name = "jsx_element" +tag_name_node_name = "identifier" + [overrides.element] line_comments = { remove = true } block_comment = ["{/* ", " */}"] diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index dbc15cc1f8..8f15be2308 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -1071,6 +1071,11 @@ impl MultiBuffer { self.history.start_transaction(now) } + pub fn last_transaction_id(&self) -> Option { + let last_transaction = self.history.undo_stack.last()?; + return Some(last_transaction.id); + } + pub fn end_transaction(&mut self, cx: &mut Context) -> Option { self.end_transaction_at(Instant::now(), cx) } diff --git a/crates/rope/src/rope.rs b/crates/rope/src/rope.rs index 5adb5c886c..21e77e6332 100644 --- a/crates/rope/src/rope.rs +++ b/crates/rope/src/rope.rs @@ -591,6 +591,7 @@ impl<'a> Cursor<'a> { } } +#[derive(Clone)] pub struct Chunks<'a> { chunks: sum_tree::Cursor<'a, Chunk, usize>, range: Range, @@ -780,6 +781,40 @@ impl<'a> Chunks<'a> { reversed, } } + + pub fn equals_str(&self, other: &str) -> bool { + let chunk = self.clone(); + if chunk.reversed { + let mut offset = other.len(); + for chunk in chunk { + if other[0..offset].ends_with(chunk) { + offset -= chunk.len(); + } else { + return false; + } + } + if offset != 0 { + return false; + } + } else { + let mut offset = 0; + for chunk in chunk { + if offset >= other.len() { + return false; + } + if other[offset..].starts_with(chunk) { + offset += chunk.len(); + } else { + return false; + } + } + if offset != other.len() { + return false; + } + } + + return true; + } } impl<'a> Iterator for Chunks<'a> { @@ -1855,6 +1890,53 @@ mod tests { } } + #[test] + fn test_chunks_equals_str() { + let text = "This is a multi-chunk\n& multi-line test string!"; + let rope = Rope::from(text); + for start in 0..text.len() { + for end in start..text.len() { + let range = start..end; + let correct_substring = &text[start..end]; + + // Test that correct range returns true + assert!(rope + .chunks_in_range(range.clone()) + .equals_str(correct_substring)); + assert!(rope + .reversed_chunks_in_range(range.clone()) + .equals_str(correct_substring)); + + // Test that all other ranges return false (unless they happen to match) + for other_start in 0..text.len() { + for other_end in other_start..text.len() { + if other_start == start && other_end == end { + continue; + } + let other_substring = &text[other_start..other_end]; + + // Only assert false if the substrings are actually different + if other_substring == correct_substring { + continue; + } + assert!(!rope + .chunks_in_range(range.clone()) + .equals_str(other_substring)); + assert!(!rope + .reversed_chunks_in_range(range.clone()) + .equals_str(other_substring)); + } + } + } + } + + let rope = Rope::from(""); + assert!(rope.chunks_in_range(0..0).equals_str("")); + assert!(rope.reversed_chunks_in_range(0..0).equals_str("")); + assert!(!rope.chunks_in_range(0..0).equals_str("foo")); + assert!(!rope.reversed_chunks_in_range(0..0).equals_str("foo")); + } + fn clip_offset(text: &str, mut offset: usize, bias: Bias) -> usize { while !text.is_char_boundary(offset) { match bias {