jsx_tag_auto_close: Fix <Foo.Bar> component auto-close (#27374)

- **support alternate tag name node names to fix autoclosing of
`<Foo.Bar>` style tags in TSX**
- **remove checks against close tag name while checking if tag is
closed**
- **move jsx tag auto close tests into jsx_tag_auto_close.rs**

Closes #27335

Release Notes:

- Fixed an issue with JSX tag auto-close where components containing a
`.` access like `<Foo.Bar>` would be auto-closed as `</>` instead of
`</Foo.Bar>`
This commit is contained in:
Ben Kunkle 2025-03-24 10:43:06 -05:00 committed by GitHub
parent 43712285bf
commit f2be201495
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 271 additions and 257 deletions

View file

@ -18034,245 +18034,6 @@ async fn test_apply_code_lens_actions_with_commands(cx: &mut gpui::TestAppContex
}); });
} }
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> { fn empty_range(row: usize, column: usize) -> Range<DisplayPoint> {
let point = DisplayPoint::new(DisplayRow(row as u32), column as u32); let point = DisplayPoint::new(DisplayRow(row as u32), column as u32);
point..point point..point

View file

@ -114,7 +114,13 @@ pub(crate) fn generate_auto_close_edits(
assert!(open_tag.kind() == config.open_tag_node_name); assert!(open_tag.kind() == config.open_tag_node_name);
let tag_name = open_tag let tag_name = open_tag
.named_child(TS_NODE_TAG_NAME_CHILD_INDEX) .named_child(TS_NODE_TAG_NAME_CHILD_INDEX)
.filter(|node| node.kind() == config.tag_name_node_name) .filter(|node| {
node.kind() == config.tag_name_node_name
|| config
.tag_name_node_name_alternates
.iter()
.any(|alternate| alternate == node.kind())
})
.map_or("".to_string(), |node| { .map_or("".to_string(), |node| {
buffer.text_for_range(node.byte_range()).collect::<String>() buffer.text_for_range(node.byte_range()).collect::<String>()
}); });
@ -174,12 +180,9 @@ pub(crate) fn generate_auto_close_edits(
* given that the naive algorithm is sufficient in the majority of cases. * 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 tag_node_name_equals = |node: &Node, name: &str| {
let is_empty = name.len() == 0; let is_empty = name.len() == 0;
if let Some(node_name) = node.named_child(TS_NODE_TAG_NAME_CHILD_INDEX) { 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(); let range = node_name.byte_range();
return buffer.text_for_range(range).equals_str(name); return buffer.text_for_range(range).equals_str(name);
} }
@ -225,11 +228,7 @@ pub(crate) fn generate_auto_close_edits(
.named_child(0) .named_child(0)
.filter(|n| n.kind() == config.open_tag_node_name) .filter(|n| n.kind() == config.open_tag_node_name)
.map_or(false, |element_open_tag_node| { .map_or(false, |element_open_tag_node| {
tag_node_name_equals( tag_node_name_equals(&element_open_tag_node, &tag_name)
&element_open_tag_node,
&config.tag_name_node_name,
&tag_name,
)
}); });
if has_open_tag_with_same_tag_name { if has_open_tag_with_same_tag_name {
doing_deep_search = true; doing_deep_search = true;
@ -258,14 +257,9 @@ pub(crate) fn generate_auto_close_edits(
let mut has_erroneous_close_tag = false; let mut has_erroneous_close_tag = false;
let mut erroneous_close_tag_node_name = ""; 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() { if let Some(name) = config.erroneous_close_tag_node_name.as_deref() {
has_erroneous_close_tag = true; has_erroneous_close_tag = true;
erroneous_close_tag_node_name = name; 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| { let is_after_open_tag = |node: &Node| {
@ -281,15 +275,15 @@ pub(crate) fn generate_auto_close_edits(
while let Some(node) = stack.pop() { while let Some(node) = stack.pop() {
let kind = node.kind(); let kind = node.kind();
if kind == config.open_tag_node_name { if kind == config.open_tag_node_name {
if tag_node_name_equals(&node, &config.tag_name_node_name, &tag_name) { if tag_node_name_equals(&node, &tag_name) {
unclosed_open_tag_count += 1; unclosed_open_tag_count += 1;
} }
} else if kind == config.close_tag_node_name { } else if kind == config.close_tag_node_name {
if tag_node_name_equals(&node, &config.tag_name_node_name, &tag_name) { if tag_node_name_equals(&node, &tag_name) {
unclosed_open_tag_count -= 1; unclosed_open_tag_count -= 1;
} }
} else if has_erroneous_close_tag && kind == erroneous_close_tag_node_name { } 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 tag_node_name_equals(&node, &tag_name) {
if !is_after_open_tag(&node) { if !is_after_open_tag(&node) {
unclosed_open_tag_count -= 1; unclosed_open_tag_count -= 1;
} }
@ -614,3 +608,256 @@ pub(crate) fn handle_from(
.detach(); .detach();
} }
} }
#[cfg(test)]
mod jsx_tag_autoclose_tests {
use crate::{
editor_tests::init_test,
test::{build_editor, editor_test_context::EditorTestContext},
};
use super::*;
use gpui::{AppContext as _, TestAppContext};
use language::language_settings::JsxTagAutoCloseSettings;
use languages::language;
use multi_buffer::ExcerptRange;
use text::Selection;
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_autocloses_dot_separated_component,
"<Component.Fooˇ" + ">" => "<Component.Foo>ˇ</Component.Foo>"
);
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>ˇ");
}
}

View file

@ -727,6 +727,11 @@ pub struct JsxTagAutoCloseConfig {
/// The name of the node found within both opening and closing /// The name of the node found within both opening and closing
/// tags that describes the tag name /// tags that describes the tag name
pub tag_name_node_name: String, pub tag_name_node_name: String,
/// Alternate Node names for tag names.
/// Specifically needed as TSX represents the name in `<Foo.Bar>`
/// as `member_expression` rather than `identifier` as usual
#[serde(default)]
pub tag_name_node_name_alternates: Vec<String>,
/// Some grammars are smart enough to detect a closing tag /// Some grammars are smart enough to detect a closing tag
/// that is not valid i.e. doesn't match it's corresponding /// that is not valid i.e. doesn't match it's corresponding
/// opening tag or does not have a corresponding opening tag /// opening tag or does not have a corresponding opening tag

View file

@ -23,6 +23,7 @@ open_tag_node_name = "jsx_opening_element"
close_tag_node_name = "jsx_closing_element" close_tag_node_name = "jsx_closing_element"
jsx_element_node_name = "jsx_element" jsx_element_node_name = "jsx_element"
tag_name_node_name = "identifier" tag_name_node_name = "identifier"
tag_name_node_name_alternates = ["member_expression"]
[overrides.element] [overrides.element]
line_comments = { remove = true } line_comments = { remove = true }