diff --git a/src/test/java/org/jetbrains/plugins/ideavim/action/MarkTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/action/MarkTest.kt index a838488a0..5c64be685 100755 --- a/src/test/java/org/jetbrains/plugins/ideavim/action/MarkTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/action/MarkTest.kt @@ -271,6 +271,186 @@ class MarkTest : VimTestCase() { assertOffset(14) } + // VIM-3731 |m| |[`| + @Test + fun testGotoPreviousMark() { + typeTextInFile( + injector.parser.parseKeys("ma" + "jmb" + "wmc" + "[`"), + """ + one two + <caret>three + four five + + """.trimIndent(), + ) + assertOffset(14) + } + + // VIM-3731 |m| |[`| + @Test + fun testGotoPreviousMarkIgnoresPlacingOrder() { + typeTextInFile( + injector.parser.parseKeys("mb" + "kma" + "jwmc" + "[`"), + """ + one two + three + <caret>four five + + """.trimIndent(), + ) + assertOffset(14) + } + + // VIM-3731 |m| |[`| + @Test + fun testGotoPreviousMarkMultipleMarksOnSamePosition() { + typeTextInFile( + injector.parser.parseKeys("mb" + "kma" + "jwmcmd" + "[`"), + """ + one two + three + <caret>four five + + """.trimIndent(), + ) + assertOffset(14) + } + + // VIM-3731 |m| |[`| + @Test + fun testGotoPreviousMarkWithCount() { + typeTextInFile( + injector.parser.parseKeys("ma" + "jmb" + "wmc" + "2[`"), + """ + one two + <caret>three + four five + + """.trimIndent(), + ) + assertOffset(8) + } + + // VIM-3731 |m| |[`| + @Test + fun testGotoPreviousMarkWithExcessiveCount() { + typeTextInFile( + injector.parser.parseKeys("ma" + "jmb" + "wmc" + "5[`"), + """ + one two + <caret>three + four five + + """.trimIndent(), + ) + assertOffset(8) + } + + // VIM-3731 |m| |[`| + @Test + fun testGotoPreviousMarkBeforeFirstMarkDoesNothing() { + typeTextInFile( + injector.parser.parseKeys("ma" + "jmb" + "wmc" + "ggw"+ "[`"), + """ + one two + <caret>three + four five + + """.trimIndent(), + ) + assertOffset(4) + } + + // VIM-3731 |m| |]`| + @Test + fun testGotoNextMark() { + typeTextInFile( + injector.parser.parseKeys("ma" + "jmb" + "wmc" + "gg" + "]`"), + """ + one two + <caret>three + four five + + """.trimIndent(), + ) + assertOffset(8) + } + + // VIM-3731 |m| |]`| + @Test + fun testGotoNextMarkIgnoresPlacingOrder() { + typeTextInFile( + injector.parser.parseKeys("mb" + "kma" + "jwmc" + "gg" + "]`"), + """ + one two + three + <caret>four five + + """.trimIndent(), + ) + assertOffset(8) + } + + // VIM-3731 |m| |]`| + @Test + fun testGotoNextMarkMultipleMarksOnSamePosition() { + typeTextInFile( + injector.parser.parseKeys("mbmd" + "kma" + "jwmc" + "ggjj" + "]`"), + """ + one two + three + <caret>four five + + """.trimIndent(), + ) + assertOffset(19) + } + + // VIM-3731 |m| |]`| + @Test + fun testGotoNextMarkWithCount() { + typeTextInFile( + injector.parser.parseKeys("ma" + "jmb" + "wmc" + "gg" + "2]`"), + """ + one two + <caret>three + four five + + """.trimIndent(), + ) + assertOffset(14) + } + + // VIM-3731 |m| |]`| + @Test + fun testGotoNextMarkWithExcessiveCount() { + typeTextInFile( + injector.parser.parseKeys("ma" + "jmb" + "wmc" + "gg" + "5]`"), + """ + one two + <caret>three + four five + + """.trimIndent(), + ) + assertOffset(19) + } + + // VIM-3731 |m| |]`| + @Test + fun testGotoNextMarkAfterLastMarkDoesNothing() { + typeTextInFile( + injector.parser.parseKeys("ma" + "jmb" + "wmc" + "ll"+ "]`"), + """ + one two + <caret>three + four five + + """.trimIndent(), + ) + assertOffset(21) + } + // |i| |`]| @Test fun testGotoLastChangePositionEnd() { @@ -543,4 +723,67 @@ class MarkTest : VimTestCase() { """.trimIndent(), ) } + + @Test + fun testMulticaretPreviousNextMark() { + configureByText( + """ + My mother <caret>taught me this trick: + if you repeat something <caret>over and over again it loses its meaning. + For example: homework, homework, homework, homework, <caret>homework, homework, homework, homework, homework. + See, nothing. + + """.trimIndent(), + ) + typeText("mawmbw") + assertState( + """ + My mother taught me <caret>this trick: + if you repeat something over and <caret>over again it loses its meaning. + For example: homework, homework, homework, homework, homework, <caret>homework, homework, homework, homework. + See, nothing. + + """.trimIndent(), + ) + typeText("[`") + assertState( + """ + My mother taught <caret>me this trick: + if you repeat something over <caret>and over again it loses its meaning. + For example: homework, homework, homework, homework, homework<caret>, homework, homework, homework, homework. + See, nothing. + + """.trimIndent(), + ) + typeText("[`") + assertState( + """ + My mother <caret>taught me this trick: + if you repeat something <caret>over and over again it loses its meaning. + For example: homework, homework, homework, homework, <caret>homework, homework, homework, homework, homework. + See, nothing. + + """.trimIndent(), + ) + typeText("[`") // Does nothing on first mark. + assertState( + """ + My mother <caret>taught me this trick: + if you repeat something <caret>over and over again it loses its meaning. + For example: homework, homework, homework, homework, <caret>homework, homework, homework, homework, homework. + See, nothing. + + """.trimIndent(), + ) + typeText("5]`") // Excessive count goes to last mark. + assertState( + """ + My mother taught <caret>me this trick: + if you repeat something over <caret>and over again it loses its meaning. + For example: homework, homework, homework, homework, homework<caret>, homework, homework, homework, homework. + See, nothing. + + """.trimIndent(), + ) + } } diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/action/motion/mark/MotionGotoMarkAction.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/action/motion/mark/MotionGotoMarkAction.kt index 1b6d4d8d3..f7768d940 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/action/motion/mark/MotionGotoMarkAction.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/action/motion/mark/MotionGotoMarkAction.kt @@ -63,3 +63,26 @@ class MotionGotoMarkNoSaveJumpAction : MotionActionHandler.ForEachCaret() { return injector.motion.moveCaretToMark(caret, mark, false) } } + +@CommandOrMotion(keys = ["]`"], modes = [Mode.NORMAL, Mode.VISUAL, Mode.OP_PENDING]) +class MotionGotoNextMarkAction: MotionGotoRelativeMarkAction(countMultiplier = 1) { +} + +@CommandOrMotion(keys = ["[`"], modes = [Mode.NORMAL, Mode.VISUAL, Mode.OP_PENDING]) +class MotionGotoPreviousMarkAction: MotionGotoRelativeMarkAction(countMultiplier = -1) { +} + +sealed class MotionGotoRelativeMarkAction(private val countMultiplier: Int) : MotionActionHandler.ForEachCaret() { + override val motionType: MotionType = MotionType.EXCLUSIVE + + override fun getOffset( + editor: VimEditor, + caret: ImmutableVimCaret, + context: ExecutionContext, + argument: Argument?, + operatorArguments: OperatorArguments, + ): Motion { + return injector.motion.moveCaretToMarkRelative(caret, operatorArguments.count1 * countMultiplier) + } + +} diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimMarkService.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimMarkService.kt index 5d6c35402..78ea497a0 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimMarkService.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimMarkService.kt @@ -45,6 +45,11 @@ interface VimMarkService { */ fun getMark(caret: ImmutableVimCaret, char: Char): Mark? + /** + * Get previous / next lowercase mark for specified caret + */ + fun getRelativeLowercaseMark(caret: ImmutableVimCaret, count: Int): Mark? + /** * Gets all marks for caret */ diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimMarkServiceBase.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimMarkServiceBase.kt index 24634c47f..30f8a44ef 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimMarkServiceBase.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimMarkServiceBase.kt @@ -121,6 +121,53 @@ abstract class VimMarkServiceBase : VimMarkService { } } + override fun getRelativeLowercaseMark(caret: ImmutableVimCaret, count: Int): Mark? { + val path = caret.editor.getPath() ?: return null + if (count == 0) return null + val marks = if (caret.isPrimary) { + getLocalMarks(path).values + } else { + caret.markStorage.getMarks().values.toSet() + } + val lowerCaseMarksWithDistinctPositions = marks.filter { LOWERCASE_MARKS.contains(it.key) } + .sortedWithAndDistinctBy(Mark.PositionSorter) + // Use a fake mark to easily find the position of the caret in the list of marks. + val caretMark = createMark(caret, '[', caret.offset) ?: return null + val result = lowerCaseMarksWithDistinctPositions.binarySearch(caretMark, Mark.PositionSorter) + val targetIndex = if (result >= 0) { + // Caret is on a mark. + result + count + } else { + val insertionPoint = -1 * (result + 1) + if ((insertionPoint == 0 && count < 0) + || (insertionPoint == lowerCaseMarksWithDistinctPositions.size && count > 0)) { + // Moving left if before first mark, or moving right after last mark. + return null + } + if (count < 0) insertionPoint + count else insertionPoint + count - 1 + } + // Excessive values of count.absoluteValue cause us to stop at the first/last mark. + val actualIndex = targetIndex.coerceIn(0, lowerCaseMarksWithDistinctPositions.lastIndex) + return lowerCaseMarksWithDistinctPositions[actualIndex] + } + + /** + * Sorts [this] using [comparator], then drops elements that are duplicate by [comparator]. + * + * This is more efficient than calling `sortedWith(comparator).distinctBy {}` because the distinct step can + * assume the input is sorted by the same Comparator. + */ + private fun List<Mark>.sortedWithAndDistinctBy(comparator: Comparator<Mark>): List<Mark> { + val sorted = this.sortedWith(Mark.PositionSorter) + return sorted.fold(ArrayList<Mark>(sorted.size)) { outputList, mark -> + val previousMark = outputList.lastOrNull() + if (previousMark == null || Mark.PositionSorter.compare(previousMark, mark) != 0) { + outputList.add(mark) + } + outputList + } + } + override fun getAllLocalMarks(caret: ImmutableVimCaret): Set<Mark> { val path = caret.editor.getPath() ?: return emptySet() val marks = if (caret.isPrimary) { diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimMotionGroup.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimMotionGroup.kt index 89f3b4280..954b8265a 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimMotionGroup.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimMotionGroup.kt @@ -83,6 +83,7 @@ interface VimMotionGroup { // Move caret to other fun moveCaretToMark(caret: ImmutableVimCaret, ch: Char, toLineStart: Boolean): Motion + fun moveCaretToMarkRelative(caret: ImmutableVimCaret, count: Int): Motion fun moveCaretToJump(editor: VimEditor, caret: ImmutableVimCaret, count: Int): Motion fun moveCaretToMatchingPair(editor: VimEditor, caret: ImmutableVimCaret): Motion diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimMotionGroupBase.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimMotionGroupBase.kt index 7f77fd4a4..5e252d2d9 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimMotionGroupBase.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimMotionGroupBase.kt @@ -307,6 +307,13 @@ abstract class VimMotionGroupBase : VimMotionGroup { return Motion.Error } + override fun moveCaretToMarkRelative(caret: ImmutableVimCaret, count: Int): Motion { + val markService = injector.markService + val mark = markService.getRelativeLowercaseMark(caret, count) ?: return Motion.Error + val offset = caret.editor.bufferPositionToOffset(BufferPosition(mark.line, mark.col, false)) + return offset.toMotionOrError() + } + override fun moveCaretToJump(editor: VimEditor, caret: ImmutableVimCaret, count: Int): Motion { val jumpService = injector.jumpService val spot = jumpService.getJumpSpot(editor) diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/mark/Marks.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/mark/Marks.kt index 3b98148c3..3e097433d 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/mark/Marks.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/mark/Marks.kt @@ -10,6 +10,7 @@ package com.maddyhome.idea.vim.mark import com.maddyhome.idea.vim.api.BufferPosition import com.maddyhome.idea.vim.api.VimEditor +import com.maddyhome.idea.vim.mark.Mark.KeySorter.ORDER import org.jetbrains.annotations.NonNls interface Mark { @@ -29,6 +30,13 @@ interface Mark { return ORDER.indexOf(o1.key) - ORDER.indexOf(o2.key) } } + // Same as in BufferPosition. + // TODO: Consider having a shared Interface / comparator for Mark and BufferPosition to avoid this duplication. + object PositionSorter: Comparator<Mark> { + override fun compare(o1: Mark, o2: Mark): Int { + return if (o1.line != o2.line) o1.line - o2.line else o1.col - o2.col + } + } } data class VimMark( diff --git a/vim-engine/src/main/resources/ksp-generated/engine_commands.json b/vim-engine/src/main/resources/ksp-generated/engine_commands.json index e312d52b1..bd9038198 100644 --- a/vim-engine/src/main/resources/ksp-generated/engine_commands.json +++ b/vim-engine/src/main/resources/ksp-generated/engine_commands.json @@ -1239,6 +1239,11 @@ "class": "com.maddyhome.idea.vim.action.motion.text.MotionSectionBackwardEndAction", "modes": "NXO" }, + { + "keys": "[`", + "class": "com.maddyhome.idea.vim.action.motion.mark.MotionGotoPreviousMarkAction", + "modes": "NXO" + }, { "keys": "[b", "class": "com.maddyhome.idea.vim.action.motion.text.MotionCamelLeftAction", @@ -1304,6 +1309,11 @@ "class": "com.maddyhome.idea.vim.action.motion.text.MotionSectionForwardStartAction", "modes": "NXO" }, + { + "keys": "]`", + "class": "com.maddyhome.idea.vim.action.motion.mark.MotionGotoNextMarkAction", + "modes": "NXO" + }, { "keys": "]b", "class": "com.maddyhome.idea.vim.action.motion.text.MotionCamelEndLeftAction",