languages: Fix Bash indentation issues with multi-cursors, newlines, and keyword outdenting (#35116)

Closes #34390

This PR fixes several Bash indentation issues:

- Adding indentation or comment using multi cursors no longer breaks
relative indentation
- Adding newline now places the cursor at the correct indent
- Typing a valid keyword triggers context-aware auto outdent

It also adds tests for all of them.

Release Notes:

- Fixed various issues with handling indentation in Bash.
This commit is contained in:
Smit Barmase 2025-07-26 04:58:10 +05:30 committed by GitHub
parent 07252c3309
commit 43d0aae617
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 459 additions and 25 deletions

1
Cargo.lock generated
View file

@ -4979,6 +4979,7 @@ dependencies = [
"text",
"theme",
"time",
"tree-sitter-bash",
"tree-sitter-html",
"tree-sitter-python",
"tree-sitter-rust",

View file

@ -110,6 +110,7 @@ tree-sitter-html.workspace = true
tree-sitter-rust.workspace = true
tree-sitter-typescript.workspace = true
tree-sitter-yaml.workspace = true
tree-sitter-bash.workspace = true
unindent.workspace = true
util = { workspace = true, features = ["test-support"] }
workspace = { workspace = true, features = ["test-support"] }

View file

@ -22585,6 +22585,435 @@ async fn test_indent_on_newline_for_python(cx: &mut TestAppContext) {
"});
}
#[gpui::test]
async fn test_tab_in_leading_whitespace_auto_indents_for_bash(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
let language = languages::language("bash", tree_sitter_bash::LANGUAGE.into());
cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
// test cursor move to start of each line on tab
// for `if`, `elif`, `else`, `while`, `for`, `case` and `function`
cx.set_state(indoc! {"
function main() {
ˇ for item in $items; do
ˇ while [ -n \"$item\" ]; do
ˇ if [ \"$value\" -gt 10 ]; then
ˇ continue
ˇ elif [ \"$value\" -lt 0 ]; then
ˇ break
ˇ else
ˇ echo \"$item\"
ˇ fi
ˇ done
ˇ done
ˇ}
"});
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
cx.assert_editor_state(indoc! {"
function main() {
ˇfor item in $items; do
ˇwhile [ -n \"$item\" ]; do
ˇif [ \"$value\" -gt 10 ]; then
ˇcontinue
ˇelif [ \"$value\" -lt 0 ]; then
ˇbreak
ˇelse
ˇecho \"$item\"
ˇfi
ˇdone
ˇdone
ˇ}
"});
// test relative indent is preserved when tab
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
cx.assert_editor_state(indoc! {"
function main() {
ˇfor item in $items; do
ˇwhile [ -n \"$item\" ]; do
ˇif [ \"$value\" -gt 10 ]; then
ˇcontinue
ˇelif [ \"$value\" -lt 0 ]; then
ˇbreak
ˇelse
ˇecho \"$item\"
ˇfi
ˇdone
ˇdone
ˇ}
"});
// test cursor move to start of each line on tab
// for `case` statement with patterns
cx.set_state(indoc! {"
function handle() {
ˇ case \"$1\" in
ˇ start)
ˇ echo \"a\"
ˇ ;;
ˇ stop)
ˇ echo \"b\"
ˇ ;;
ˇ *)
ˇ echo \"c\"
ˇ ;;
ˇ esac
ˇ}
"});
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
cx.assert_editor_state(indoc! {"
function handle() {
ˇcase \"$1\" in
ˇstart)
ˇecho \"a\"
ˇ;;
ˇstop)
ˇecho \"b\"
ˇ;;
ˇ*)
ˇecho \"c\"
ˇ;;
ˇesac
ˇ}
"});
}
#[gpui::test]
async fn test_indent_after_input_for_bash(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
let language = languages::language("bash", tree_sitter_bash::LANGUAGE.into());
cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
// test indents on comment insert
cx.set_state(indoc! {"
function main() {
ˇ for item in $items; do
ˇ while [ -n \"$item\" ]; do
ˇ if [ \"$value\" -gt 10 ]; then
ˇ continue
ˇ elif [ \"$value\" -lt 0 ]; then
ˇ break
ˇ else
ˇ echo \"$item\"
ˇ fi
ˇ done
ˇ done
ˇ}
"});
cx.update_editor(|e, window, cx| e.handle_input("#", window, cx));
cx.assert_editor_state(indoc! {"
function main() {
#ˇ for item in $items; do
#ˇ while [ -n \"$item\" ]; do
#ˇ if [ \"$value\" -gt 10 ]; then
#ˇ continue
#ˇ elif [ \"$value\" -lt 0 ]; then
#ˇ break
#ˇ else
#ˇ echo \"$item\"
#ˇ fi
#ˇ done
#ˇ done
#ˇ}
"});
}
#[gpui::test]
async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
let language = languages::language("bash", tree_sitter_bash::LANGUAGE.into());
cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
// test `else` auto outdents when typed inside `if` block
cx.set_state(indoc! {"
if [ \"$1\" = \"test\" ]; then
echo \"foo bar\"
ˇ
"});
cx.update_editor(|editor, window, cx| {
editor.handle_input("else", window, cx);
});
cx.assert_editor_state(indoc! {"
if [ \"$1\" = \"test\" ]; then
echo \"foo bar\"
elseˇ
"});
// test `elif` auto outdents when typed inside `if` block
cx.set_state(indoc! {"
if [ \"$1\" = \"test\" ]; then
echo \"foo bar\"
ˇ
"});
cx.update_editor(|editor, window, cx| {
editor.handle_input("elif", window, cx);
});
cx.assert_editor_state(indoc! {"
if [ \"$1\" = \"test\" ]; then
echo \"foo bar\"
elifˇ
"});
// test `fi` auto outdents when typed inside `else` block
cx.set_state(indoc! {"
if [ \"$1\" = \"test\" ]; then
echo \"foo bar\"
else
echo \"bar baz\"
ˇ
"});
cx.update_editor(|editor, window, cx| {
editor.handle_input("fi", window, cx);
});
cx.assert_editor_state(indoc! {"
if [ \"$1\" = \"test\" ]; then
echo \"foo bar\"
else
echo \"bar baz\"
fiˇ
"});
// test `done` auto outdents when typed inside `while` block
cx.set_state(indoc! {"
while read line; do
echo \"$line\"
ˇ
"});
cx.update_editor(|editor, window, cx| {
editor.handle_input("done", window, cx);
});
cx.assert_editor_state(indoc! {"
while read line; do
echo \"$line\"
doneˇ
"});
// test `done` auto outdents when typed inside `for` block
cx.set_state(indoc! {"
for file in *.txt; do
cat \"$file\"
ˇ
"});
cx.update_editor(|editor, window, cx| {
editor.handle_input("done", window, cx);
});
cx.assert_editor_state(indoc! {"
for file in *.txt; do
cat \"$file\"
doneˇ
"});
// test `esac` auto outdents when typed inside `case` block
cx.set_state(indoc! {"
case \"$1\" in
start)
echo \"foo bar\"
;;
stop)
echo \"bar baz\"
;;
ˇ
"});
cx.update_editor(|editor, window, cx| {
editor.handle_input("esac", window, cx);
});
cx.assert_editor_state(indoc! {"
case \"$1\" in
start)
echo \"foo bar\"
;;
stop)
echo \"bar baz\"
;;
esacˇ
"});
// test `*)` auto outdents when typed inside `case` block
cx.set_state(indoc! {"
case \"$1\" in
start)
echo \"foo bar\"
;;
ˇ
"});
cx.update_editor(|editor, window, cx| {
editor.handle_input("*)", window, cx);
});
cx.assert_editor_state(indoc! {"
case \"$1\" in
start)
echo \"foo bar\"
;;
*)ˇ
"});
// test `fi` outdents to correct level with nested if blocks
cx.set_state(indoc! {"
if [ \"$1\" = \"test\" ]; then
echo \"outer if\"
if [ \"$2\" = \"debug\" ]; then
echo \"inner if\"
ˇ
"});
cx.update_editor(|editor, window, cx| {
editor.handle_input("fi", window, cx);
});
cx.assert_editor_state(indoc! {"
if [ \"$1\" = \"test\" ]; then
echo \"outer if\"
if [ \"$2\" = \"debug\" ]; then
echo \"inner if\"
fiˇ
"});
}
#[gpui::test]
async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) {
init_test(cx, |_| {});
update_test_language_settings(cx, |settings| {
settings.defaults.extend_comment_on_newline = Some(false);
});
let mut cx = EditorTestContext::new(cx).await;
let language = languages::language("bash", tree_sitter_bash::LANGUAGE.into());
cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
// test correct indent after newline on comment
cx.set_state(indoc! {"
# COMMENT:ˇ
"});
cx.update_editor(|editor, window, cx| {
editor.newline(&Newline, window, cx);
});
cx.assert_editor_state(indoc! {"
# COMMENT:
ˇ
"});
// test correct indent after newline after `then`
cx.set_state(indoc! {"
if [ \"$1\" = \"test\" ]; thenˇ
"});
cx.update_editor(|editor, window, cx| {
editor.newline(&Newline, window, cx);
});
cx.run_until_parked();
cx.assert_editor_state(indoc! {"
if [ \"$1\" = \"test\" ]; then
ˇ
"});
// test correct indent after newline after `else`
cx.set_state(indoc! {"
if [ \"$1\" = \"test\" ]; then
elseˇ
"});
cx.update_editor(|editor, window, cx| {
editor.newline(&Newline, window, cx);
});
cx.run_until_parked();
cx.assert_editor_state(indoc! {"
if [ \"$1\" = \"test\" ]; then
else
ˇ
"});
// test correct indent after newline after `elif`
cx.set_state(indoc! {"
if [ \"$1\" = \"test\" ]; then
elifˇ
"});
cx.update_editor(|editor, window, cx| {
editor.newline(&Newline, window, cx);
});
cx.run_until_parked();
cx.assert_editor_state(indoc! {"
if [ \"$1\" = \"test\" ]; then
elif
ˇ
"});
// test correct indent after newline after `do`
cx.set_state(indoc! {"
for file in *.txt; doˇ
"});
cx.update_editor(|editor, window, cx| {
editor.newline(&Newline, window, cx);
});
cx.run_until_parked();
cx.assert_editor_state(indoc! {"
for file in *.txt; do
ˇ
"});
// test correct indent after newline after case pattern
cx.set_state(indoc! {"
case \"$1\" in
start)ˇ
"});
cx.update_editor(|editor, window, cx| {
editor.newline(&Newline, window, cx);
});
cx.run_until_parked();
cx.assert_editor_state(indoc! {"
case \"$1\" in
start)
ˇ
"});
// test correct indent after newline after case pattern
cx.set_state(indoc! {"
case \"$1\" in
start)
;;
*)ˇ
"});
cx.update_editor(|editor, window, cx| {
editor.newline(&Newline, window, cx);
});
cx.run_until_parked();
cx.assert_editor_state(indoc! {"
case \"$1\" in
start)
;;
*)
ˇ
"});
// test correct indent after newline after function opening brace
cx.set_state(indoc! {"
function test() {ˇ}
"});
cx.update_editor(|editor, window, cx| {
editor.newline(&Newline, window, cx);
});
cx.run_until_parked();
cx.assert_editor_state(indoc! {"
function test() {
ˇ
}
"});
// test no extra indent after semicolon on same line
cx.set_state(indoc! {"
echo \"test\"
"});
cx.update_editor(|editor, window, cx| {
editor.newline(&Newline, window, cx);
});
cx.run_until_parked();
cx.assert_editor_state(indoc! {"
echo \"test\";
ˇ
"});
}
fn empty_range(row: usize, column: usize) -> Range<DisplayPoint> {
let point = DisplayPoint::new(DisplayRow(row as u32), column as u32);
point..point

View file

@ -18,17 +18,20 @@ brackets = [
{ start = "in", end = "esac", close = false, newline = true, not_in = ["comment", "string"] },
]
### WARN: the following is not working when you insert an `elif` just before an else
### example: (^ is cursor after hitting enter)
### ```
### if true; then
### foo
### elif
### ^
### else
### bar
### fi
### ```
increase_indent_pattern = "(^|\\s+|;)(do|then|in|else|elif)\\b.*$"
decrease_indent_pattern = "(^|\\s+|;)(fi|done|esac|else|elif)\\b.*$"
# make sure to test each line mode & block mode
auto_indent_using_last_non_empty_line = false
increase_indent_pattern = "^\\s*(\\b(else|elif)\\b|([^#]+\\b(do|then|in)\\b)|([\\w\\*]+\\)))\\s*$"
decrease_indent_patterns = [
{ pattern = "^\\s*elif\\b.*", valid_after = ["if", "elif"] },
{ pattern = "^\\s*else\\b.*", valid_after = ["if", "elif", "for", "while"] },
{ pattern = "^\\s*fi\\b.*", valid_after = ["if", "elif", "else"] },
{ pattern = "^\\s*done\\b.*", valid_after = ["for", "while"] },
{ pattern = "^\\s*esac\\b.*", valid_after = ["case"] },
{ pattern = "^\\s*[\\w\\*]+\\)\\s*$", valid_after = ["case_item"] },
]
# We can't use decrease_indent_patterns simply for elif, because
# there is bug in tree sitter which throws ERROR on if match.
#
# This is workaround. That means, elif will outdents with despite
# of wrong context. Like using elif after else.
decrease_indent_pattern = "(^|\\s+|;)(elif)\\b.*$"

View file

@ -1,12 +1,12 @@
(function_definition
"function"?
body: (
_
"{" @start
"}" @end
)) @indent
(_ "[" "]" @end) @indent
(_ "{" "}" @end) @indent
(_ "(" ")" @end) @indent
(array
"(" @start
")" @end
) @indent
(function_definition) @start.function
(if_statement) @start.if
(elif_clause) @start.elif
(else_clause) @start.else
(for_statement) @start.for
(while_statement) @start.while
(case_statement) @start.case
(case_item) @start.case_item