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 <cole@zed.dev>
Co-authored-by: Max Brunsfeld <max@zed.dev>
Co-authored-by: Marshall Bowers <git@maxdeviant.com>
Co-authored-by: Mikayla <mikayla@zed.dev>
Co-authored-by: Peter Tripp <peter@zed.dev>
This commit is contained in:
Ben Kunkle 2025-03-06 08:36:10 -06:00 committed by GitHub
parent 05df3d1bd6
commit ff25fa24e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1207 additions and 149 deletions

1
Cargo.lock generated
View file

@ -4154,6 +4154,7 @@ dependencies = [
"inline_completion",
"itertools 0.14.0",
"language",
"languages",
"linkify",
"log",
"lsp",

View file

@ -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.

View file

@ -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"] }

View file

@ -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<Task<()>>,
@ -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();
}

View file

@ -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,
"<divˇ" + ">" => "<div>ˇ</div>"
);
check!(
test_basic_nested,
"<div><divˇ</div>" + ">" => "<div><div>ˇ</div></div>"
);
check!(
test_basic_ignore_already_closed,
"<div><divˇ</div></div>" + ">" => "<div><div>ˇ</div></div>"
);
check!(
test_doesnt_autoclose_closing_tag,
"</divˇ" + ">" => "</div>ˇ"
);
check!(
test_jsx_attr,
"<div attr={</div>}ˇ" + ">" => "<div attr={</div>}>ˇ</div>"
);
check!(
test_ignores_closing_tags_in_expr_block,
"<div><divˇ{</div>}</div>" + ">" => "<div><div>ˇ</div>{</div>}</div>"
);
check!(
test_doesnt_autoclose_on_gt_in_expr,
"<div attr={1 ˇ" + ">" => "<div attr={1 >ˇ"
);
check!(
test_ignores_closing_tags_with_different_tag_names,
"<div><divˇ</div></span>" + ">" => "<div><div>ˇ</div></div></span>"
);
check!(
test_autocloses_in_jsx_expression,
"<div>{<divˇ}</div>" + ">" => "<div>{<div>ˇ</div>}</div>"
);
check!(
test_doesnt_autoclose_already_closed_in_jsx_expression,
"<div>{<divˇ</div>}</div>" + ">" => "<div>{<div>ˇ</div>}</div>"
);
check!(
test_autocloses_fragment,
"" + ">" => "<>ˇ</>"
);
check!(
test_does_not_include_type_argument_in_autoclose_tag_name,
"<Component<T> attr={boolean_value}ˇ" + ">" => "<Component<T> attr={boolean_value}>ˇ</Component>"
);
check!(
test_does_not_autoclose_doctype,
"<!DOCTYPE htmlˇ" + ">" => "<!DOCTYPE html>ˇ"
);
check!(
test_does_not_autoclose_comment,
"<!-- comment --ˇ" + ">" => "<!-- comment -->ˇ"
);
check!(
test_multi_cursor_autoclose_same_tag,
r#"
<divˇ
<divˇ
"#
+ ">" =>
r#"
<div>ˇ</div>
<div>ˇ</div>
"#
);
check!(
test_multi_cursor_autoclose_different_tags,
r#"
<divˇ
<spanˇ
"#
+ ">" =>
r#"
<div>ˇ</div>
<span>ˇ</span>
"#
);
check!(
test_multi_cursor_autoclose_some_dont_autoclose_others,
r#"
<divˇ
<div /ˇ
<spanˇ</span>
<!DOCTYPE htmlˇ
</headˇ
<Component<T>ˇ
ˇ
"#
+ ">" =>
r#"
<div>ˇ</div>
<div />ˇ
<span>ˇ</span>
<!DOCTYPE html>ˇ
</head>ˇ
<Component<T>>ˇ</Component>
>ˇ
"#
);
check!(
test_doesnt_mess_up_trailing_text,
"<divˇfoobar" + ">" => "<div>ˇ</div>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("<div", cx);
buf.set_language(
Some(language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into())),
cx,
);
buf
});
let buffer_b = cx.new(|cx| {
let mut buf = language::Buffer::local("<pre", cx);
buf.set_language(
Some(language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into())),
cx,
);
buf
});
let buffer_c = cx.new(|cx| {
let buf = language::Buffer::local("<span", cx);
buf
});
let buffer = cx.new(|cx| {
let mut buf = MultiBuffer::new(language::Capability::ReadWrite);
buf.push_excerpts(
buffer_a,
[ExcerptRange {
context: text::Anchor::MIN..text::Anchor::MAX,
primary: None,
}],
cx,
);
buf.push_excerpts(
buffer_b,
[ExcerptRange {
context: text::Anchor::MIN..text::Anchor::MAX,
primary: None,
}],
cx,
);
buf.push_excerpts(
buffer_c,
[ExcerptRange {
context: text::Anchor::MIN..text::Anchor::MAX,
primary: None,
}],
cx,
);
buf
});
let editor = cx.add_window(|window, cx| build_editor(buffer.clone(), window, cx));
let mut cx = EditorTestContext::for_editor(editor, cx).await;
cx.update_editor(|editor, window, cx| {
editor.change_selections(None, window, cx, |selections| {
selections.select(vec![
Selection::from_offset(4),
Selection::from_offset(9),
Selection::from_offset(15),
])
})
});
cx.run_until_parked();
cx.update_editor(|editor, window, cx| {
editor.handle_input(">", window, cx);
});
cx.run_until_parked();
cx.assert_editor_state("<div>ˇ</div>\n<pre>ˇ</pre>\n<span>ˇ");
}
}
fn empty_range(row: usize, column: usize) -> Range<DisplayPoint> {
let point = DisplayPoint::new(DisplayRow(row as u32), column as u32);
point..point

View file

@ -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<usize>,
}
/// 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<usize>],
config: &JsxTagAutoCloseConfig,
) -> Option<Vec<JsxTagCompletionState>> {
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::<String>();
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<usize>],
config: &JsxTagAutoCloseConfig,
state: Vec<JsxTagCompletionState>,
) -> Result<Vec<(Range<Anchor>, 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::<String>()
});
/*
* 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 `</div>` tag without a corresponding opening tag exists 25 lines away
* and the user typed in `<div>`, intuitively we still want to auto-close it because
* the other `</div>` 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
* ```
* <div>
* <div>|cursor here|
* </div>
* ```
* in Astro/vue/svelte tree-sitter represented the tree as
* (
* (jsx_element
* (jsx_opening_element
* "<div>")
* )
* (jsx_element
* (jsx_opening_element
* "<div>") // <- cursor is here
* (jsx_closing_element
* "</div>")
* )
* )
* 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<MultiBuffer>,
cx: &Context<Editor>,
) {
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<language::BufferId, clock::Global>;
pub(crate) fn construct_initial_buffer_versions_map<
D: ToOffset + Copy,
_S: Into<std::sync::Arc<str>>,
>(
editor: &Editor,
edits: &[(Range<D>, _S)],
cx: &Context<Editor>,
) -> 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<Editor>,
) {
if !editor.jsx_tag_auto_close_enabled_in_any_buffer {
return;
}
struct JsxAutoCloseEditContext {
buffer: Entity<language::Buffer>,
config: language::JsxTagAutoCloseConfig,
edits: Vec<Range<usize>>,
}
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<()> {
// <div>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::<Vec<_>>();
this.update_in(&mut cx, |this, window, cx| {
this.change_selections_inner(None, false, window, cx, |s| {
s.select(base_selections);
});
})
.ok()?;
}
Some(())
})
.detach();
}
}

View file

@ -3080,6 +3080,25 @@ impl BufferSnapshot {
.last()
}
pub fn smallest_syntax_layer_containing<D: ToOffset>(
&self,
range: Range<D>,
) -> Option<SyntaxLayer> {
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<Language>> {
self.language.as_ref()

View file

@ -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<JsxTagAutoCloseConfig>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Default, JsonSchema)]
@ -697,6 +700,34 @@ pub struct LanguageMatcher {
pub first_line_pattern: Option<Regex>,
}
/// 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<String>,
/// 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<String>,
}
/// 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,

View file

@ -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<PrettierSettings>,
/// Whether to automatically close JSX tags.
#[serde(default)]
pub jsx_tag_auto_close: Option<JsxTagAutoCloseSettings>,
/// 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<String, serde_json::Value>,
}
#[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;

View file

@ -121,9 +121,9 @@ impl SyntaxLayerContent {
pub struct SyntaxLayer<'a> {
/// The language for this layer.
pub language: &'a Arc<Language>,
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<Language>,
tree: tree_sitter::Tree,
offset: (usize, tree_sitter::Point),
pub offset: (usize, tree_sitter::Point),
}
#[derive(Debug, Clone)]

View file

@ -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 = ["{/* ", " */}"]

View file

@ -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<LanguageRegistry>, 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<Fn>` 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<dyn ContextProvider>)
};
($name:literal, $adapters:expr) => {
let config = load_config($name);
// typeck helper
let adapters: Vec<Arc<dyn LspAdapter>> = $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<Arc<dyn LspAdapter>> = $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<Arc<dyn LspAdapter>> = $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<dyn LspAdapter>]);
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<dyn ToolchainLister>)
};
() => {
None
};
}
macro_rules! adapters {
($($item:expr),+ $(,)?) => {
vec![
$(Arc::new($item) as Arc<dyn LspAdapter>,)*
]
};
() => {
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<dyn ToolchainLister>
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()
}
}

View file

@ -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 = ["{/* ", " */}"]

View file

@ -1071,6 +1071,11 @@ impl MultiBuffer {
self.history.start_transaction(now)
}
pub fn last_transaction_id(&self) -> Option<TransactionId> {
let last_transaction = self.history.undo_stack.last()?;
return Some(last_transaction.id);
}
pub fn end_transaction(&mut self, cx: &mut Context<Self>) -> Option<TransactionId> {
self.end_transaction_at(Instant::now(), cx)
}

View file

@ -591,6 +591,7 @@ impl<'a> Cursor<'a> {
}
}
#[derive(Clone)]
pub struct Chunks<'a> {
chunks: sum_tree::Cursor<'a, Chunk, usize>,
range: Range<usize>,
@ -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 {