editor: Add prefix on newline in documentation block (e.g. JSDoc) (#30768)

Smit Barmase created

Closes #8973

- [x] Tests


https://github.com/user-attachments/assets/7fc6608f-1c11-4c70-a69b-34bfa8f789a2

Release Notes:

- Added auto-insertion of asterisk (*) prefix when creating new lines
within JSDoc comment blocks.

Change summary

crates/editor/src/editor.rs                 |  96 +++++++++++++++++++++
crates/editor/src/editor_tests.rs           | 101 +++++++++++++++++++++++
crates/language/src/language.rs             |  25 +++++
crates/languages/src/javascript/config.toml |   2 
crates/languages/src/tsx/config.toml        |   2 
crates/languages/src/typescript/config.toml |   2 
6 files changed, 225 insertions(+), 3 deletions(-)

Detailed changes

crates/editor/src/editor.rs 🔗

@@ -3930,12 +3930,12 @@ impl Editor {
                         let (comment_delimiter, insert_extra_newline) = if let Some(language) =
                             &language_scope
                         {
-                            let insert_extra_newline =
+                            let mut insert_extra_newline =
                                 insert_extra_newline_brackets(&buffer, start..end, language)
                                     || insert_extra_newline_tree_sitter(&buffer, start..end);
 
                             // Comment extension on newline is allowed only for cursor selections
-                            let comment_delimiter = maybe!({
+                            let mut comment_delimiter = maybe!({
                                 if !selection_is_empty {
                                     return None;
                                 }
@@ -3974,6 +3974,93 @@ impl Editor {
                                     None
                                 }
                             });
+
+                            if comment_delimiter.is_none() {
+                                comment_delimiter = maybe!({
+                                    if !selection_is_empty {
+                                        return None;
+                                    }
+
+                                    if !multi_buffer.language_settings(cx).extend_comment_on_newline
+                                    {
+                                        return None;
+                                    }
+
+                                    let doc_block = language.documentation_block();
+                                    let doc_block_prefix = doc_block.first()?;
+                                    let doc_block_suffix = doc_block.last()?;
+
+                                    let doc_comment_prefix =
+                                        language.documentation_comment_prefix()?;
+
+                                    let (snapshot, range) = buffer
+                                        .buffer_line_for_row(MultiBufferRow(start_point.row))?;
+
+                                    let cursor_is_after_prefix = {
+                                        let doc_block_prefix_len = doc_block_prefix.len();
+                                        let max_len_of_delimiter = std::cmp::max(
+                                            doc_comment_prefix.len(),
+                                            doc_block_prefix_len,
+                                        );
+                                        let index_of_first_non_whitespace = snapshot
+                                            .chars_for_range(range.clone())
+                                            .take_while(|c| c.is_whitespace())
+                                            .count();
+                                        let doc_line_candidate = snapshot
+                                            .chars_for_range(range.clone())
+                                            .skip(index_of_first_non_whitespace)
+                                            .take(max_len_of_delimiter)
+                                            .collect::<String>();
+                                        if doc_line_candidate.starts_with(doc_block_prefix.as_ref())
+                                        {
+                                            index_of_first_non_whitespace + doc_block_prefix_len
+                                                <= start_point.column as usize
+                                        } else if doc_line_candidate
+                                            .starts_with(doc_comment_prefix.as_ref())
+                                        {
+                                            index_of_first_non_whitespace + doc_comment_prefix.len()
+                                                <= start_point.column as usize
+                                        } else {
+                                            false
+                                        }
+                                    };
+
+                                    let cursor_is_before_suffix_if_exits = {
+                                        let whitespace_char_from_last = snapshot
+                                            .reversed_chars_for_range(range.clone())
+                                            .take_while(|c| c.is_whitespace())
+                                            .count();
+                                        let mut line_rev_iter = snapshot
+                                            .reversed_chars_for_range(range)
+                                            .skip(whitespace_char_from_last);
+                                        let suffix_exists = doc_block_suffix
+                                            .chars()
+                                            .rev()
+                                            .all(|char| line_rev_iter.next() == Some(char));
+                                        if suffix_exists {
+                                            let max_point =
+                                                snapshot.line_len(start_point.row) as usize;
+                                            let cursor_is_before_suffix = whitespace_char_from_last
+                                                + doc_block_suffix.len()
+                                                + start_point.column as usize
+                                                <= max_point;
+                                            if cursor_is_before_suffix {
+                                                insert_extra_newline = true;
+                                            }
+                                            cursor_is_before_suffix
+                                        } else {
+                                            true
+                                        }
+                                    };
+
+                                    if cursor_is_after_prefix && cursor_is_before_suffix_if_exits {
+                                        Some(doc_comment_prefix.clone())
+                                    } else {
+                                        None
+                                    }
+                                });
+                            }
+
                             (comment_delimiter, insert_extra_newline)
                         } else {
                             (None, false)
@@ -3987,11 +4074,14 @@ impl Editor {
                             String::with_capacity(1 + capacity_for_delimiter + indent.len as usize);
                         new_text.push('\n');
                         new_text.extend(indent.chars());
+
                         if let Some(delimiter) = &comment_delimiter {
                             new_text.push_str(delimiter);
                         }
+
                         if insert_extra_newline {
-                            new_text = new_text.repeat(2);
+                            new_text.push('\n');
+                            new_text.extend(indent.chars());
                         }
 
                         let anchor = buffer.anchor_after(end);

crates/editor/src/editor_tests.rs 🔗

@@ -2797,6 +2797,107 @@ async fn test_newline_comments(cx: &mut TestAppContext) {
     "});
 }
 
+#[gpui::test]
+async fn test_newline_documentation_comments(cx: &mut TestAppContext) {
+    init_test(cx, |settings| {
+        settings.defaults.tab_size = NonZeroU32::new(4)
+    });
+
+    let language = Arc::new(Language::new(
+        LanguageConfig {
+            documentation_block: Some(vec!["/**".into(), "*/".into()]),
+            documentation_comment_prefix: Some("*".into()),
+            ..LanguageConfig::default()
+        },
+        None,
+    ));
+    {
+        let mut cx = EditorTestContext::new(cx).await;
+        cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
+        cx.set_state(indoc! {"
+        /**ˇ
+    "});
+
+        cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+        cx.assert_editor_state(indoc! {"
+        /**
+        *ˇ
+    "});
+        // Ensure that if cursor is before the comment start, we do not actually insert a comment prefix.
+        cx.set_state(indoc! {"
+        ˇ/**
+    "});
+        cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+        cx.assert_editor_state(indoc! {"
+
+        ˇ/**
+    "});
+        // Ensure that if cursor is between it doesn't add comment prefix.
+        cx.set_state(indoc! {"
+        /*ˇ*
+    "});
+        cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+        cx.assert_editor_state(indoc! {"
+        /*
+        ˇ*
+    "});
+        // Ensure that if suffix exists on same line after cursor it adds new line.
+        cx.set_state(indoc! {"
+        /**ˇ*/
+    "});
+        cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+        cx.assert_editor_state(indoc! {"
+        /**
+        *ˇ
+        */
+    "});
+        // Ensure that it detects suffix after existing prefix.
+        cx.set_state(indoc! {"
+        /**ˇ/
+    "});
+        cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+        cx.assert_editor_state(indoc! {"
+        /**
+        ˇ/
+    "});
+        // Ensure that if suffix exists on same line before cursor it does not add comment prefix.
+        cx.set_state(indoc! {"
+        /** */ˇ
+    "});
+        cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+        cx.assert_editor_state(indoc! {"
+        /** */
+        ˇ
+    "});
+        // Ensure that if suffix exists on same line before cursor it does not add comment prefix.
+        cx.set_state(indoc! {"
+        /**
+        *
+        */ˇ
+    "});
+        cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+        cx.assert_editor_state(indoc! {"
+        /**
+        *
+        */
+        ˇ
+    "});
+    }
+    // Ensure that comment continuations can be disabled.
+    update_test_language_settings(cx, |settings| {
+        settings.defaults.extend_comment_on_newline = Some(false);
+    });
+    let mut cx = EditorTestContext::new(cx).await;
+    cx.set_state(indoc! {"
+        /**ˇ
+    "});
+    cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+    cx.assert_editor_state(indoc! {"
+        /**
+        ˇ
+    "});
+}
+
 #[gpui::test]
 fn test_insert_with_old_selections(cx: &mut TestAppContext) {
     init_test(cx, |_| {});

crates/language/src/language.rs 🔗

@@ -755,6 +755,12 @@ pub struct LanguageConfig {
     /// A list of preferred debuggers for this language.
     #[serde(default)]
     pub debuggers: IndexSet<SharedString>,
+    /// A character to add as a prefix when a new line is added to a documentation block.
+    #[serde(default)]
+    pub documentation_comment_prefix: Option<Arc<str>>,
+    /// Returns string documentation block of this language should start with.
+    #[serde(default)]
+    pub documentation_block: Option<Vec<Arc<str>>>,
 }
 
 #[derive(Clone, Debug, Serialize, Deserialize, Default, JsonSchema)]
@@ -883,6 +889,8 @@ impl Default for LanguageConfig {
             completion_query_characters: Default::default(),
             debuggers: Default::default(),
             significant_indentation: Default::default(),
+            documentation_comment_prefix: None,
+            documentation_block: None,
         }
     }
 }
@@ -1802,6 +1810,23 @@ impl LanguageScope {
             .unwrap_or(false)
     }
 
+    /// A character to add as a prefix when a new line is added to a documentation block.
+    ///
+    /// Used for documentation styles that require a leading character on each line,
+    /// such as the asterisk in JSDoc, Javadoc, etc.
+    pub fn documentation_comment_prefix(&self) -> Option<&Arc<str>> {
+        self.language.config.documentation_comment_prefix.as_ref()
+    }
+
+    /// Returns prefix and suffix for documentation block of this language.
+    pub fn documentation_block(&self) -> &[Arc<str>] {
+        self.language
+            .config
+            .documentation_block
+            .as_ref()
+            .map_or([].as_slice(), |e| e.as_slice())
+    }
+
     /// Returns a list of bracket pairs for a given language with an additional
     /// piece of information about whether the particular bracket pair is currently active for a given language.
     pub fn brackets(&self) -> impl Iterator<Item = (&BracketPair, bool)> {

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

@@ -20,6 +20,8 @@ tab_size = 2
 scope_opt_in_language_servers = ["tailwindcss-language-server", "emmet-language-server"]
 prettier_parser_name = "babel"
 debuggers = ["JavaScript"]
+documentation_comment_prefix = "*"
+documentation_block = ["/**", "*/"]
 
 [jsx_tag_auto_close]
 open_tag_node_name = "jsx_opening_element"

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

@@ -18,6 +18,8 @@ scope_opt_in_language_servers = ["tailwindcss-language-server", "emmet-language-
 prettier_parser_name = "typescript"
 tab_size = 2
 debuggers = ["JavaScript"]
+documentation_comment_prefix = "*"
+documentation_block = ["/**", "*/"]
 
 [jsx_tag_auto_close]
 open_tag_node_name = "jsx_opening_element"

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

@@ -18,6 +18,8 @@ word_characters = ["#", "$"]
 prettier_parser_name = "typescript"
 tab_size = 2
 debuggers = ["JavaScript"]
+documentation_comment_prefix = "*"
+documentation_block = ["/**", "*/"]
 
 [overrides.string]
 completion_query_characters = ["."]