Support bash autoindenting (#24156)

Ben Kunkle created

Creates an indents.scm file for bash and adds regexes for
`{increase,decrease}_indent_pattern` in
`crates/languages/src/bash/config.toml`
so that autoindent works as expected in bash

Note that this PR does not attempt to handle all cases where indenting
might be desired in bash. I am aiming to support ~80% of what people
want while avoiding the more gnarly/edge cases like indented blocks in
case statements and indenting for associative arrays.
This is done with the explicit hope that someone (possibly from the
community) more familiar with and passionate about bash can come through
at a later date and handle those cases

Closes #23628

Release Notes:

- Add basic support for autoindent functionality in bash/shell files

Change summary

crates/languages/Cargo.toml           |   1 
crates/languages/src/bash.rs          | 100 +++++++++++++++++++++++++++++
crates/languages/src/bash/config.toml |  19 ++++
crates/languages/src/bash/indents.scm |  12 +++
4 files changed, 130 insertions(+), 2 deletions(-)

Detailed changes

crates/languages/Cargo.toml 🔗

@@ -93,3 +93,4 @@ tree-sitter-python.workspace = true
 tree-sitter-go.workspace = true
 tree-sitter-c.workspace = true
 tree-sitter-css.workspace = true
+tree-sitter-bash.workspace = true

crates/languages/src/bash.rs 🔗

@@ -15,3 +15,103 @@ pub(super) fn bash_task_context() -> ContextProviderWithTasks {
         },
     ]))
 }
+
+#[cfg(test)]
+mod tests {
+    use gpui::{AppContext as _, BorrowAppContext, Context, TestAppContext};
+    use language::{language_settings::AllLanguageSettings, AutoindentMode, Buffer};
+    use settings::SettingsStore;
+    use std::num::NonZeroU32;
+
+    #[gpui::test]
+    async fn test_bash_autoindent(cx: &mut TestAppContext) {
+        cx.executor().set_block_on_ticks(usize::MAX..=usize::MAX);
+        let language = crate::language("bash", tree_sitter_bash::LANGUAGE.into());
+        cx.update(|cx| {
+            let test_settings = SettingsStore::test(cx);
+            cx.set_global(test_settings);
+            language::init(cx);
+            cx.update_global::<SettingsStore, _>(|store, cx| {
+                store.update_user_settings::<AllLanguageSettings>(cx, |s| {
+                    s.defaults.tab_size = NonZeroU32::new(2)
+                });
+            });
+        });
+
+        cx.new(|cx| {
+            let mut buffer = Buffer::local("", cx).with_language(language, cx);
+
+            let expect_indents_to =
+                |buffer: &mut Buffer, cx: &mut Context<Buffer>, input: &str, expected: &str| {
+                    buffer.edit( [(0..buffer.len(), input)], Some(AutoindentMode::EachLine), cx, );
+                    assert_eq!(buffer.text(), expected);
+                };
+
+            // indent function correctly
+            expect_indents_to(
+                &mut buffer,
+                cx,
+                "function name() {\necho \"Hello, World!\"\n}",
+                "function name() {\n  echo \"Hello, World!\"\n}",
+            );
+
+            // indent if-else correctly
+            expect_indents_to(
+                &mut buffer,
+                cx,
+                "if true;then\nfoo\nelse\nbar\nfi",
+                "if true;then\n  foo\nelse\n  bar\nfi",
+            );
+
+            // indent if-elif-else correctly
+            expect_indents_to(
+                &mut buffer,
+                cx,
+                "if true;then\nfoo\nelif true;then\nbar\nelse\nbar\nfi",
+                "if true;then\n  foo\nelif true;then\n  bar\nelse\n  bar\nfi",
+            );
+
+            // indent case-when-else correctly
+            expect_indents_to(
+                &mut buffer,
+                cx,
+                "case $1 in\nfoo) echo \"Hello, World!\";;\n*) echo \"Unknown argument\";;\nesac",
+                "case $1 in\n  foo) echo \"Hello, World!\";;\n  *) echo \"Unknown argument\";;\nesac",
+            );
+
+            // indent for-loop correctly
+            expect_indents_to(
+                &mut buffer,
+                cx,
+                "for i in {1..10};do\nfoo\ndone",
+                "for i in {1..10};do\n  foo\ndone",
+            );
+
+            // indent while-loop correctly
+            expect_indents_to(
+                &mut buffer,
+                cx,
+                "while true; do\nfoo\ndone",
+                "while true; do\n  foo\ndone",
+            );
+
+            // indent array correctly
+            expect_indents_to(
+                &mut buffer,
+                cx,
+                "array=(\n1\n2\n3\n)",
+                "array=(\n  1\n  2\n  3\n)",
+            );
+
+            // indents non-"function" function correctly
+            expect_indents_to(
+                &mut buffer,
+                cx,
+                "foo() {\necho \"Hello, World!\"\n}",
+                "foo() {\n  echo \"Hello, World!\"\n}",
+            );
+
+            buffer
+        });
+    }
+}

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

@@ -6,8 +6,23 @@ line_comments = ["# "]
 first_line_pattern = '^#!.*\b(?:ash|bash|dash|sh|zsh)\b'
 brackets = [
     { start = "[", end = "]", close = true, newline = false },
-    { start = "(", end = ")", close = true, newline = false },
-    { start = "{", end = "}", close = true, newline = false },
+    { start = "(", end = ")", close = true, newline = true },
+    { start = "{", end = "}", close = true, newline = true },
     { start = "\"", end = "\"", close = true, newline = false, not_in = ["comment", "string"] },
     { start = "'", end = "'", close = true, newline = false, not_in = ["string", "comment"] },
 ]
+
+### 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