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

Smit Barmase created

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.

Change summary

Cargo.lock                            |   1 
crates/editor/Cargo.toml              |   1 
crates/editor/src/editor_tests.rs     | 429 +++++++++++++++++++++++++++++
crates/languages/src/bash/config.toml |  31 +
crates/languages/src/bash/indents.scm |  22 
5 files changed, 459 insertions(+), 25 deletions(-)

Detailed changes

Cargo.lock 🔗

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

crates/editor/Cargo.toml 🔗

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

crates/editor/src/editor_tests.rs 🔗

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

crates/languages/src/bash/config.toml 🔗

@@ -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.*$"

crates/languages/src/bash/indents.scm 🔗

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