Repository: intellij-rust/intellij-rust
Issue: Uncommenting a doc comment

## Environment

* **IntelliJ Rust plugin version:** 0.2.121.3059-201-stable
* **Rust toolchain version:** Rust 1.45 nightly
* **IDE name and version:** CLion 2020.1.1
* **Operating system:** Windows 10

## Problem description
Uncommenting (`CTRL+ '/'`) on a doc comment (`///`) results in a single remaining slash:

before:
```rust
/// this is a doc comment
```

after:
```rust
/ this is a doc comment
```

expected:
```rust
this is a doc comment
```

## Solution
The implementation right now is to just remove two '/' characters at the beginning of the line. My proposal would be to extend this to removing two or three characters, depending on how many are written. 

Changed files:
src/main/kotlin/org/rust/ide/commenter/RsCommenter.kt, src/test/kotlin/org/rust/ide/commenter/RsCommenterTest.kt, src/test/kotlin/org/rust/RsTestBase.kt

diff --git a/src/main/kotlin/org/rust/ide/commenter/RsCommenter.kt b/src/main/kotlin/org/rust/ide/commenter/RsCommenter.kt
index 895521c06..39897aebe 100644
--- a/src/main/kotlin/org/rust/ide/commenter/RsCommenter.kt
+++ b/src/main/kotlin/org/rust/ide/commenter/RsCommenter.kt
@@ -5,14 +5,31 @@
 
 package org.rust.ide.commenter
 
+import com.intellij.application.options.CodeStyle
+import com.intellij.codeInsight.generation.CommenterDataHolder
+import com.intellij.codeInsight.generation.SelfManagingCommenter
+import com.intellij.codeInsight.generation.SelfManagingCommenterUtil
 import com.intellij.lang.CodeDocumentationAwareCommenter
 import com.intellij.lang.Commenter
+import com.intellij.openapi.editor.Document
+import com.intellij.openapi.util.TextRange
 import com.intellij.psi.PsiComment
+import com.intellij.psi.PsiFile
 import com.intellij.psi.tree.IElementType
+import com.intellij.util.text.CharArrayUtil
+import org.rust.lang.RsLanguage
 import org.rust.lang.core.parser.RustParserDefinition.Companion.BLOCK_COMMENT
+import org.rust.lang.core.psi.RS_EOL_COMMENTS
+import org.rust.lang.doc.psi.RsDocKind
 
-class RsCommenter : Commenter, CodeDocumentationAwareCommenter {
-    // act like there are no doc or line comments, these are handled separately
+data class CommentHolder(val file: PsiFile) : CommenterDataHolder() {
+    fun useSpaceAfterLineComment(): Boolean {
+        return CodeStyle.getLanguageSettings(file, RsLanguage).LINE_COMMENT_ADD_SPACE
+    }
+}
+
+class RsCommenter : Commenter, CodeDocumentationAwareCommenter, SelfManagingCommenter<CommentHolder> {
+    // act like there are no doc comments, these are handled in `RsEnterInLineCommentHandler`
     override fun isDocumentationComment(element: PsiComment?) = false
     override fun getDocumentationCommentTokenType(): IElementType? = null
     override fun getDocumentationCommentLinePrefix(): String? = null
@@ -27,8 +44,84 @@ class RsCommenter : Commenter, CodeDocumentationAwareCommenter {
     override fun getBlockCommentPrefix(): String = "/*"
     override fun getBlockCommentSuffix(): String = "*/"
 
-    // for nested comments
-
+    // unused because we implement SelfManagingCommenter
     override fun getCommentedBlockCommentPrefix(): String = "*//*"
     override fun getCommentedBlockCommentSuffix(): String = "*//*"
+
+    override fun getBlockCommentPrefix(selectionStart: Int, document: Document, data: CommentHolder): String? =
+        blockCommentPrefix
+
+    override fun getBlockCommentSuffix(selectionEnd: Int, document: Document, data: CommentHolder): String? =
+        blockCommentSuffix
+
+    override fun getBlockCommentRange(
+        selectionStart: Int,
+        selectionEnd: Int,
+        document: Document,
+        data: CommentHolder
+    ): TextRange? =
+        SelfManagingCommenterUtil.getBlockCommentRange(selectionStart, selectionEnd, document, blockCommentPrefix, blockCommentSuffix)
+
+    override fun insertBlockComment(
+        startOffset: Int,
+        endOffset: Int,
+        document: Document,
+        data: CommentHolder?
+    ): TextRange =
+        SelfManagingCommenterUtil.insertBlockComment(startOffset, endOffset, document, blockCommentPrefix, blockCommentSuffix)
+
+    override fun uncommentBlockComment(
+        startOffset: Int,
+        endOffset: Int,
+        document: Document,
+        data: CommentHolder?
+    ) =
+        SelfManagingCommenterUtil.uncommentBlockComment(startOffset, endOffset, document, blockCommentPrefix, blockCommentSuffix)
+
+    override fun isLineCommented(line: Int, offset: Int, document: Document, data: CommentHolder): Boolean {
+        return getStartLineComment(line, document, data.file)?.isEolComment ?: false
+    }
+
+    override fun commentLine(line: Int, offset: Int, document: Document, data: CommentHolder) {
+        val addSpace = data.useSpaceAfterLineComment()
+        document.insertString(offset, "//" + if (addSpace) " " else "")
+    }
+
+    override fun uncommentLine(line: Int, offset: Int, document: Document, data: CommentHolder) {
+        val prefixLen = LINE_PREFIXES.find { CharArrayUtil.regionMatches(document.charsSequence, offset, it) }?.length
+            ?: return
+        val hasSpace = data.useSpaceAfterLineComment() && CharArrayUtil.regionMatches(document.charsSequence, offset + prefixLen, " ")
+        document.deleteString(offset, offset + prefixLen + if (hasSpace) 1 else 0)
+    }
+
+    override fun getCommentPrefix(line: Int, document: Document, data: CommentHolder): String? = lineCommentPrefix
+
+    override fun createBlockCommentingState(
+        selectionStart: Int,
+        selectionEnd: Int,
+        document: Document,
+        file: PsiFile
+    ): CommentHolder? =
+        CommentHolder(file)
+
+    override fun createLineCommentingState(
+        startLine: Int,
+        endLine: Int,
+        document: Document,
+        file: PsiFile
+    ): CommentHolder? =
+        CommentHolder(file)
+
+    companion object {
+        private val LINE_PREFIXES = listOf(RsDocKind.OuterEol.prefix, RsDocKind.InnerEol.prefix, "//")
+    }
+}
+
+private fun getStartLineComment(line: Int, document: Document, file: PsiFile): PsiComment? {
+    val offset = document.getLineStartOffset(line)
+    val chars = document.charsSequence
+    return file.findElementAt(CharArrayUtil.shiftForward(chars, offset, " \\t")) as? PsiComment
 }
+
+private val PsiComment.isEolComment: Boolean
+    get() = this.tokenType in RS_EOL_COMMENTS
diff --git a/src/test/kotlin/org/rust/RsTestBase.kt b/src/test/kotlin/org/rust/RsTestBase.kt
index cba88db7b..b92671254 100644
--- a/src/test/kotlin/org/rust/RsTestBase.kt
+++ b/src/test/kotlin/org/rust/RsTestBase.kt
@@ -208,7 +208,7 @@ abstract class RsTestBase : BasePlatformTestCase(), RsTestCase {
         myFixture.checkResult(replaceCaretMarker(after))
     }
 
-    protected fun checkEditorAction(
+    protected open fun checkEditorAction(
         @Language("Rust") before: String,
         @Language("Rust") after: String,
         actionId: String,
diff --git a/src/test/kotlin/org/rust/ide/commenter/RsCommenterTest.kt b/src/test/kotlin/org/rust/ide/commenter/RsCommenterTest.kt
index 9a5acc609..3016cf07c 100644
--- a/src/test/kotlin/org/rust/ide/commenter/RsCommenterTest.kt
+++ b/src/test/kotlin/org/rust/ide/commenter/RsCommenterTest.kt
@@ -5,12 +5,19 @@
 
 package org.rust.ide.commenter
 
+import com.intellij.application.options.CodeStyle
+import com.intellij.openapi.actionSystem.ActionManager
+import com.intellij.openapi.actionSystem.EmptyAction
 import com.intellij.openapi.actionSystem.IdeActions
+import com.intellij.openapiext.Testmark
+import com.intellij.psi.codeStyle.CommonCodeStyleSettings
+import org.intellij.lang.annotations.Language
 import org.rust.RsTestBase
+import org.rust.lang.RsLanguage
+import kotlin.reflect.KMutableProperty0
 
 class RsCommenterTest : RsTestBase() {
-
-    fun `test single line`() = checkEditorAction("""
+    fun `test single line`() = checkOption(settings()::LINE_COMMENT_ADD_SPACE, """
         fn <caret>double(x: i32) -> i32 {
             x * 2
         }
@@ -18,9 +25,13 @@ class RsCommenterTest : RsTestBase() {
         // fn double(x: i32) -> i32 {
             x <caret>* 2
         }
+    """, """
+        //fn double(x: i32) -> i32 {
+            x<caret> * 2
+        }
     """, IdeActions.ACTION_COMMENT_LINE)
 
-    fun `test multi line`() = checkEditorAction("""
+    fun `test multi line`() = checkOption(settings()::LINE_COMMENT_ADD_SPACE, """
         fn doub<selection>le(x: i32) -> i32 {
             x</selection> * 2
         }
@@ -28,6 +39,10 @@ class RsCommenterTest : RsTestBase() {
         // fn doub<selection>le(x: i32) -> i32 {
         //     x</selection> * 2
         }
+    """, """
+        //fn doub<selection>le(x: i32) -> i32 {
+        //    x</selection> * 2
+        }
     """, IdeActions.ACTION_COMMENT_LINE)
 
     fun `test single line block`() = checkEditorAction("""
@@ -60,17 +75,15 @@ class RsCommenterTest : RsTestBase() {
         </selection>}
     """, """
         fn fib(x: i32) -> i32 {
-            /*
-            match x {
+        /*    match x {
                 0 => 1,
                 1 => 1,
                 _ => fib(x - 2) + fib(x - 1)
             }
-            */
-        }
+        */}
     """, IdeActions.ACTION_COMMENT_BLOCK)
 
-    fun `test single line uncomment`() = checkEditorAction("""
+    fun `test single line uncomment`() = checkOption(settings()::LINE_COMMENT_ADD_SPACE, """
         //fn d<caret>ouble(x: i32) -> i32 {
         //    x * 2
         }
@@ -78,9 +91,27 @@ class RsCommenterTest : RsTestBase() {
         fn double(x: i32) -> i32 {
         //  <caret>  x * 2
         }
+    """, """
+        fn double(x: i32) -> i32 {
+        //  <caret>  x * 2
+        }
     """, IdeActions.ACTION_COMMENT_LINE)
 
-    fun `test multi line uncomment`() = checkEditorAction("""
+    fun `test single line uncomment with space after`() = checkOption(settings()::LINE_COMMENT_ADD_SPACE, """
+        // fn d<caret>ouble(x: i32) -> i32 {
+        //    x * 2
+        }
+    """, """
+        fn double(x: i32) -> i32 {
+        //  <caret>  x * 2
+        }
+    """, """
+         fn double(x: i32) -> i32 {
+        //   <caret> x * 2
+        }
+    """, IdeActions.ACTION_COMMENT_LINE, trimIndent = false)
+
+    fun `test multi line uncomment`() = checkOption(settings()::LINE_COMMENT_ADD_SPACE, """
         //fn doub<selection>le(x: i32) -> i32 {
         //     x</selection> * 2
         }
@@ -88,6 +119,10 @@ class RsCommenterTest : RsTestBase() {
         fn doub<selection>le(x: i32) -> i32 {
             x</selection> * 2
         }
+    """, """
+        fn doub<selection>le(x: i32) -> i32 {
+             x</selection> * 2
+        }
     """, IdeActions.ACTION_COMMENT_LINE)
 
     fun `test single line block uncomment`() = checkEditorAction("""
@@ -110,13 +145,51 @@ class RsCommenterTest : RsTestBase() {
         }
     """, IdeActions.ACTION_COMMENT_BLOCK)
 
-    fun `test single line uncomment with space`() = checkEditorAction("""
-        // fn d<caret>ouble(x: i32) -> i32 {
-        //     x * 2
+    fun `test single line uncomment with space before`() = checkOption(settings()::LINE_COMMENT_ADD_SPACE, """
+         //fn d<caret>ouble(x: i32) -> i32 {
+        //    x * 2
+        }
+    """, """
+         fn double(x: i32) -> i32 {
+        //   <caret> x * 2
+        }
+    """, """
+         fn double(x: i32) -> i32 {
+        //   <caret> x * 2
+        }
+    """, IdeActions.ACTION_COMMENT_LINE, trimIndent = false)
+
+    fun `test outer doc comment uncomment`() = checkOption(settings()::LINE_COMMENT_ADD_SPACE, """
+        /// doc<caret>
+        fn double(x: i32) -> i32 {
+            x * 2
+        }
+    """, """
+        doc
+        fn <caret>double(x: i32) -> i32 {
+            x * 2
         }
     """, """
+         doc
+        fn d<caret>ouble(x: i32) -> i32 {
+            x * 2
+        }
+    """, IdeActions.ACTION_COMMENT_LINE)
+
+    fun `test inner doc comment uncomment`() = checkOption(settings()::LINE_COMMENT_ADD_SPACE, """
+        //! doc<caret>
         fn double(x: i32) -> i32 {
-        //  <caret>   x * 2
+            x * 2
+        }
+    """, """
+        doc
+        fn <caret>double(x: i32) -> i32 {
+            x * 2
+        }
+    """, """
+         doc
+        fn d<caret>ouble(x: i32) -> i32 {
+            x * 2
         }
     """, IdeActions.ACTION_COMMENT_LINE)
 
@@ -125,12 +198,12 @@ class RsCommenterTest : RsTestBase() {
             x * 2
         }
     """, """
-        fn double<selection>/*(x: i32*//* foobar *//*) -> i32*/</selection> {
+        fn double<selection>/*(x: i32/* foobar */) -> i32*/</selection> {
             x * 2
         }
-    """, IdeActions.ACTION_COMMENT_BLOCK) // FIXME
+    """, IdeActions.ACTION_COMMENT_BLOCK)
 
-    fun `test indented single line comment`() = checkEditorAction("""
+    fun `test indented single line comment`() = checkOption(settings()::LINE_COMMENT_ADD_SPACE, """
         fn double(x: i32) -> i32 {
             x<caret> * 2
         }
@@ -138,6 +211,30 @@ class RsCommenterTest : RsTestBase() {
         fn double(x: i32) -> i32 {
             // x * 2
         }<caret>
+    """, """
+        fn double(x: i32) -> i32 {
+            //x * 2
+        }<caret>
+    """, IdeActions.ACTION_COMMENT_LINE)
+
+    fun `test outer doc uncomment inside`() = checkEditorAction("""
+        ///
+        ///<caret>
+        ///
+    """, """
+        ///
+
+        <caret>///
+    """, IdeActions.ACTION_COMMENT_LINE)
+
+    fun `test inner doc uncomment inside`() = checkEditorAction("""
+        //!
+        //!<caret>
+        //!
+    """, """
+        //!
+
+        <caret>//!
     """, IdeActions.ACTION_COMMENT_LINE)
 
     fun `test complete block comment`() = checkEditorAction("""
@@ -147,4 +244,70 @@ class RsCommenterTest : RsTestBase() {
         <caret>
          */
     """, IdeActions.ACTION_EDITOR_ENTER)
+
+    fun `test move caret after commenting empty line`() = checkOption(settings()::LINE_COMMENT_ADD_SPACE, """
+        <caret>
+        fn foo() {}
+    """, """
+        // <caret>
+        fn foo() {}
+    """, """
+        //<caret>
+        fn foo() {}
+    """, IdeActions.ACTION_COMMENT_LINE)
+
+    private fun checkOption(
+        optionProperty: KMutableProperty0<Boolean>,
+        @Language("Rust") before: String,
+        @Language("Rust") afterOn: String,
+        @Language("Rust") afterOff: String,
+        actionId: String,
+        trimIndent: Boolean = true
+    ) {
+        val initialValue = optionProperty.get()
+        optionProperty.set(true)
+        try {
+            checkEditorAction(before, afterOn, actionId, trimIndent = trimIndent)
+            optionProperty.set(false)
+            checkEditorAction(before, afterOff, actionId, trimIndent = trimIndent)
+        } finally {
+            optionProperty.set(initialValue)
+        }
+    }
+
+    override fun checkEditorAction(
+        before: String,
+        after: String,
+        actionId: String,
+        trimIndent: Boolean,
+        testmark: Testmark?
+    ) {
+        super.checkEditorAction(before, after, actionId, trimIndent, testmark)
+        resetActionManagerState()
+    }
+
+    private fun settings(): CommonCodeStyleSettings = CodeStyle.getSettings(project).getCommonSettings(RsLanguage)
+
+    /**
+     * Resets [com.intellij.openapi.actionSystem.impl.ActionManagerImpl.myPrevPerformedActionId].
+     * Otherwise it affect [com.intellij.codeInsight.generation.CommentByLineCommentHandler.invoke]
+     * (see `startingNewLineComment` condition there).
+     */
+    private fun resetActionManagerState() {
+        myFixture.performEditorAction(EMPTY_ACTION_ID)
+    }
+
+    override fun setUp() {
+        super.setUp()
+        ActionManager.getInstance().registerAction(EMPTY_ACTION_ID, EmptyAction.createEmptyAction("empty", null, true))
+    }
+
+    override fun tearDown() {
+        ActionManager.getInstance().unregisterAction(EMPTY_ACTION_ID)
+        super.tearDown()
+    }
+
+    companion object {
+        private const val EMPTY_ACTION_ID = "!!!EmptyAction"
+    }
 }