From be2884d80061493ab1999d73ffa51dfb4f59639d Mon Sep 17 00:00:00 2001
From: TornaxO7 <tornax@proton.me>
Date: Sat, 19 Oct 2024 11:48:07 +0200
Subject: [PATCH] Continue line comments (#10996)

---
 helix-core/src/comment.rs  | 169 +++++++++++++++++++++++++++----------
 helix-term/src/commands.rs |  97 +++++++++++++++------
 2 files changed, 192 insertions(+), 74 deletions(-)

diff --git a/helix-core/src/comment.rs b/helix-core/src/comment.rs
index 536b710a..42702187 100644
--- a/helix-core/src/comment.rs
+++ b/helix-core/src/comment.rs
@@ -9,6 +9,24 @@ use crate::{
 use helix_stdx::rope::RopeSliceExt;
 use std::borrow::Cow;
 
+pub const DEFAULT_COMMENT_TOKEN: &str = "//";
+
+/// Returns the longest matching comment token of the given line (if it exists).
+pub fn get_comment_token<'a, S: AsRef<str>>(
+    text: RopeSlice,
+    tokens: &'a [S],
+    line_num: usize,
+) -> Option<&'a str> {
+    let line = text.line(line_num);
+    let start = line.first_non_whitespace_char()?;
+
+    tokens
+        .iter()
+        .map(AsRef::as_ref)
+        .filter(|token| line.slice(start..).starts_with(token))
+        .max_by_key(|token| token.len())
+}
+
 /// Given text, a comment token, and a set of line indices, returns the following:
 /// - Whether the given lines should be considered commented
 ///     - If any of the lines are uncommented, all lines are considered as such.
@@ -28,21 +46,20 @@ fn find_line_comment(
     let mut min = usize::MAX; // minimum col for first_non_whitespace_char
     let mut margin = 1;
     let token_len = token.chars().count();
+
     for line in lines {
         let line_slice = text.line(line);
         if let Some(pos) = line_slice.first_non_whitespace_char() {
             let len = line_slice.len_chars();
 
-            if pos < min {
-                min = pos;
-            }
+            min = std::cmp::min(min, pos);
 
             // line can be shorter than pos + token len
             let fragment = Cow::from(line_slice.slice(pos..std::cmp::min(pos + token.len(), len)));
 
+            // as soon as one of the non-blank lines doesn't have a comment, the whole block is
+            // considered uncommented.
             if fragment != token {
-                // as soon as one of the non-blank lines doesn't have a comment, the whole block is
-                // considered uncommented.
                 commented = false;
             }
 
@@ -56,6 +73,7 @@ fn find_line_comment(
             to_change.push(line);
         }
     }
+
     (commented, to_change, min, margin)
 }
 
@@ -63,7 +81,7 @@ fn find_line_comment(
 pub fn toggle_line_comments(doc: &Rope, selection: &Selection, token: Option<&str>) -> Transaction {
     let text = doc.slice(..);
 
-    let token = token.unwrap_or("//");
+    let token = token.unwrap_or(DEFAULT_COMMENT_TOKEN);
     let comment = Tendril::from(format!("{} ", token));
 
     let mut lines: Vec<usize> = Vec::with_capacity(selection.len());
@@ -317,56 +335,87 @@ pub fn split_lines_of_selection(text: RopeSlice, selection: &Selection) -> Selec
 mod test {
     use super::*;
 
-    #[test]
-    fn test_find_line_comment() {
-        // four lines, two space indented, except for line 1 which is blank.
-        let mut doc = Rope::from("  1\n\n  2\n  3");
-        // select whole document
-        let mut selection = Selection::single(0, doc.len_chars() - 1);
+    mod find_line_comment {
+        use super::*;
 
-        let text = doc.slice(..);
+        #[test]
+        fn not_commented() {
+            // four lines, two space indented, except for line 1 which is blank.
+            let doc = Rope::from("  1\n\n  2\n  3");
 
-        let res = find_line_comment("//", text, 0..3);
-        // (commented = true, to_change = [line 0, line 2], min = col 2, margin = 0)
-        assert_eq!(res, (false, vec![0, 2], 2, 0));
+            let text = doc.slice(..);
 
-        // comment
-        let transaction = toggle_line_comments(&doc, &selection, None);
-        transaction.apply(&mut doc);
-        selection = selection.map(transaction.changes());
+            let res = find_line_comment("//", text, 0..3);
+            // (commented = false, to_change = [line 0, line 2], min = col 2, margin = 0)
+            assert_eq!(res, (false, vec![0, 2], 2, 0));
+        }
 
-        assert_eq!(doc, "  // 1\n\n  // 2\n  // 3");
+        #[test]
+        fn is_commented() {
+            // three lines where the second line is empty.
+            let doc = Rope::from("// hello\n\n// there");
 
-        // uncomment
-        let transaction = toggle_line_comments(&doc, &selection, None);
-        transaction.apply(&mut doc);
-        selection = selection.map(transaction.changes());
-        assert_eq!(doc, "  1\n\n  2\n  3");
-        assert!(selection.len() == 1); // to ignore the selection unused warning
+            let res = find_line_comment("//", doc.slice(..), 0..3);
 
-        // 0 margin comments
-        doc = Rope::from("  //1\n\n  //2\n  //3");
-        // reset the selection.
-        selection = Selection::single(0, doc.len_chars() - 1);
+            // (commented = true, to_change = [line 0, line 2], min = col 0, margin = 1)
+            assert_eq!(res, (true, vec![0, 2], 0, 1));
+        }
+    }
 
-        let transaction = toggle_line_comments(&doc, &selection, None);
-        transaction.apply(&mut doc);
-        selection = selection.map(transaction.changes());
-        assert_eq!(doc, "  1\n\n  2\n  3");
-        assert!(selection.len() == 1); // to ignore the selection unused warning
+    // TODO: account for uncommenting with uneven comment indentation
+    mod toggle_line_comment {
+        use super::*;
 
-        // 0 margin comments, with no space
-        doc = Rope::from("//");
-        // reset the selection.
-        selection = Selection::single(0, doc.len_chars() - 1);
+        #[test]
+        fn comment() {
+            // four lines, two space indented, except for line 1 which is blank.
+            let mut doc = Rope::from("  1\n\n  2\n  3");
+            // select whole document
+            let selection = Selection::single(0, doc.len_chars() - 1);
 
-        let transaction = toggle_line_comments(&doc, &selection, None);
-        transaction.apply(&mut doc);
-        selection = selection.map(transaction.changes());
-        assert_eq!(doc, "");
-        assert!(selection.len() == 1); // to ignore the selection unused warning
+            let transaction = toggle_line_comments(&doc, &selection, None);
+            transaction.apply(&mut doc);
 
-        // TODO: account for uncommenting with uneven comment indentation
+            assert_eq!(doc, "  // 1\n\n  // 2\n  // 3");
+        }
+
+        #[test]
+        fn uncomment() {
+            let mut doc = Rope::from("  // 1\n\n  // 2\n  // 3");
+            let mut selection = Selection::single(0, doc.len_chars() - 1);
+
+            let transaction = toggle_line_comments(&doc, &selection, None);
+            transaction.apply(&mut doc);
+            selection = selection.map(transaction.changes());
+
+            assert_eq!(doc, "  1\n\n  2\n  3");
+            assert!(selection.len() == 1); // to ignore the selection unused warning
+        }
+
+        #[test]
+        fn uncomment_0_margin_comments() {
+            let mut doc = Rope::from("  //1\n\n  //2\n  //3");
+            let mut selection = Selection::single(0, doc.len_chars() - 1);
+
+            let transaction = toggle_line_comments(&doc, &selection, None);
+            transaction.apply(&mut doc);
+            selection = selection.map(transaction.changes());
+
+            assert_eq!(doc, "  1\n\n  2\n  3");
+            assert!(selection.len() == 1); // to ignore the selection unused warning
+        }
+
+        #[test]
+        fn uncomment_0_margin_comments_with_no_space() {
+            let mut doc = Rope::from("//");
+            let mut selection = Selection::single(0, doc.len_chars() - 1);
+
+            let transaction = toggle_line_comments(&doc, &selection, None);
+            transaction.apply(&mut doc);
+            selection = selection.map(transaction.changes());
+            assert_eq!(doc, "");
+            assert!(selection.len() == 1); // to ignore the selection unused warning
+        }
     }
 
     #[test]
@@ -413,4 +462,32 @@ mod test {
         transaction.apply(&mut doc);
         assert_eq!(doc, "");
     }
+
+    /// Test, if `get_comment_tokens` works, even if the content of the file includes chars, whose
+    /// byte size unequal the amount of chars
+    #[test]
+    fn test_get_comment_with_char_boundaries() {
+        let rope = Rope::from("ยทยท");
+        let tokens = ["//", "///"];
+
+        assert_eq!(
+            super::get_comment_token(rope.slice(..), tokens.as_slice(), 0),
+            None
+        );
+    }
+
+    /// Test for `get_comment_token`.
+    ///
+    /// Assuming the comment tokens are stored as `["///", "//"]`, `get_comment_token` should still
+    /// return `///` instead of `//` if the user is in a doc-comment section.
+    #[test]
+    fn test_use_longest_comment() {
+        let text = Rope::from("    /// amogus");
+        let tokens = ["///", "//"];
+
+        assert_eq!(
+            super::get_comment_token(text.slice(..), tokens.as_slice(), 0),
+            Some("///")
+        );
+    }
 }
diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs
index b1c29378..ee2949fa 100644
--- a/helix-term/src/commands.rs
+++ b/helix-term/src/commands.rs
@@ -22,8 +22,8 @@ use helix_core::{
     encoding, find_workspace,
     graphemes::{self, next_grapheme_boundary, RevRopeGraphemes},
     history::UndoKind,
-    increment, indent,
-    indent::IndentStyle,
+    increment,
+    indent::{self, IndentStyle},
     line_ending::{get_line_ending_of_str, line_end_char_index},
     match_brackets,
     movement::{self, move_vertically_visual, Direction},
@@ -3467,31 +3467,51 @@ fn open(cx: &mut Context, open: Open) {
             )
         };
 
-        let indent = indent::indent_for_newline(
-            doc.language_config(),
-            doc.syntax(),
-            &doc.config.load().indent_heuristic,
-            &doc.indent_style,
-            doc.tab_width(),
-            text,
-            line_num,
-            line_end_index,
-            cursor_line,
-        );
+        let continue_comment_token = doc
+            .language_config()
+            .and_then(|config| config.comment_tokens.as_ref())
+            .and_then(|tokens| comment::get_comment_token(text, tokens, cursor_line));
+
+        let line = text.line(cursor_line);
+        let indent = match line.first_non_whitespace_char() {
+            Some(pos) if continue_comment_token.is_some() => line.slice(..pos).to_string(),
+            _ => indent::indent_for_newline(
+                doc.language_config(),
+                doc.syntax(),
+                &doc.config.load().indent_heuristic,
+                &doc.indent_style,
+                doc.tab_width(),
+                text,
+                line_num,
+                line_end_index,
+                cursor_line,
+            ),
+        };
 
         let indent_len = indent.len();
         let mut text = String::with_capacity(1 + indent_len);
         text.push_str(doc.line_ending.as_str());
         text.push_str(&indent);
+
+        if let Some(token) = continue_comment_token {
+            text.push_str(token);
+            text.push(' ');
+        }
+
         let text = text.repeat(count);
 
         // calculate new selection ranges
         let pos = offs + line_end_index + line_end_offset_width;
+        let comment_len = continue_comment_token
+            .map(|token| token.len() + 1) // `+ 1` for the extra space added
+            .unwrap_or_default();
         for i in 0..count {
             // pos                    -> beginning of reference line,
-            // + (i * (1+indent_len)) -> beginning of i'th line from pos
-            // + indent_len ->        -> indent for i'th line
-            ranges.push(Range::point(pos + (i * (1 + indent_len)) + indent_len));
+            // + (i * (1+indent_len + comment_len)) -> beginning of i'th line from pos (possibly including comment token)
+            // + indent_len + comment_len ->        -> indent for i'th line
+            ranges.push(Range::point(
+                pos + (i * (1 + indent_len + comment_len)) + indent_len + comment_len,
+            ));
         }
 
         offs += text.chars().count();
@@ -3929,6 +3949,11 @@ pub mod insert {
 
             let mut new_text = String::new();
 
+            let continue_comment_token = doc
+                .language_config()
+                .and_then(|config| config.comment_tokens.as_ref())
+                .and_then(|tokens| comment::get_comment_token(text, tokens, current_line));
+
             // If the current line is all whitespace, insert a line ending at the beginning of
             // the current line. This makes the current line empty and the new line contain the
             // indentation of the old line.
@@ -3938,17 +3963,22 @@ pub mod insert {
 
                 (line_start, line_start, new_text.chars().count())
             } else {
-                let indent = indent::indent_for_newline(
-                    doc.language_config(),
-                    doc.syntax(),
-                    &doc.config.load().indent_heuristic,
-                    &doc.indent_style,
-                    doc.tab_width(),
-                    text,
-                    current_line,
-                    pos,
-                    current_line,
-                );
+                let line = text.line(current_line);
+
+                let indent = match line.first_non_whitespace_char() {
+                    Some(pos) if continue_comment_token.is_some() => line.slice(..pos).to_string(),
+                    _ => indent::indent_for_newline(
+                        doc.language_config(),
+                        doc.syntax(),
+                        &doc.config.load().indent_heuristic,
+                        &doc.indent_style,
+                        doc.tab_width(),
+                        text,
+                        current_line,
+                        pos,
+                        current_line,
+                    ),
+                };
 
                 // If we are between pairs (such as brackets), we want to
                 // insert an additional line which is indented one level
@@ -3958,19 +3988,30 @@ pub mod insert {
                     .and_then(|pairs| pairs.get(prev))
                     .map_or(false, |pair| pair.open == prev && pair.close == curr);
 
-                let local_offs = if on_auto_pair {
+                let local_offs = if let Some(token) = continue_comment_token {
+                    new_text.push_str(doc.line_ending.as_str());
+                    new_text.push_str(&indent);
+                    new_text.push_str(token);
+                    new_text.push(' ');
+                    new_text.chars().count()
+                } else if on_auto_pair {
+                    // line where the cursor will be
                     let inner_indent = indent.clone() + doc.indent_style.as_str();
                     new_text.reserve_exact(2 + indent.len() + inner_indent.len());
                     new_text.push_str(doc.line_ending.as_str());
                     new_text.push_str(&inner_indent);
+
+                    // line where the matching pair will be
                     let local_offs = new_text.chars().count();
                     new_text.push_str(doc.line_ending.as_str());
                     new_text.push_str(&indent);
+
                     local_offs
                 } else {
                     new_text.reserve_exact(1 + indent.len());
                     new_text.push_str(doc.line_ending.as_str());
                     new_text.push_str(&indent);
+
                     new_text.chars().count()
                 };