1
0
mirror of https://github.com/chylex/IntelliJ-IdeaVim.git synced 2025-05-06 03:34:03 +02:00

Rewrite next/previous word motions and objects

This commit is contained in:
Matt Ellis 2025-02-06 14:16:54 +01:00 committed by Alex Pláte
parent b5937e885d
commit 1a8789b50c
11 changed files with 157 additions and 71 deletions

View File

@ -1044,10 +1044,19 @@ two
@TestWithoutNeovim(reason = SkipNeovimReason.UNCLEAR)
@Test
fun testLastWord() {
typeTextInFile(injector.parser.parseKeys("w"), "${c}one\n")
typeTextInFile(injector.parser.parseKeys("w"), "${c}one")
assertOffset(2)
}
@TestWithoutNeovim(reason = SkipNeovimReason.UNCLEAR)
@Test
fun testLastWord2() {
typeTextInFile(injector.parser.parseKeys("w"), "${c}one\n")
// Counter-intuitive, but correct. There is no next word, so we get to the end of the current word, skip whitespace
// and move past the end of the file. Behaviour matches Vim - `w` will move to the next line (which is empty)
assertOffset(4)
}
// |b|
@Test
fun testWordBackwardsAtFirstLineWithWhitespaceInFront() {

View File

@ -128,7 +128,7 @@ class InsertDeletePreviousWordActionTest : VimTestCase() {
fun `test delete starting from the last character of the file`() {
// This test was originally trying to delete the previous word with the caret positioned at the end of line and
// recorded different behaviour to Vim. The problem was that the caret was incorrectly positioned _passed_ the end
// of the line, and indeed passed the end of the file.
// of the line, and indeed past the end of the file.
// This placement is valid, both in IdeaVim and Vim, but only when `:set virtualedit=onemore` is set. The test was
// showing a bug in the implementation in this scenario. The test is now explicit in what it's trying to do, and
// matches Vim's behaviour.

View File

@ -788,19 +788,12 @@ class MotionOuterWordActionTest : VimTestCase() {
)
}
// TODO: Fix this bug
@VimBehaviorDiffers(originalVimAfter =
"Lorem${s} ipsum --dolor${c} ${se}sit amet, consectetur adipiscing elit",
description = "First aw should select whitespace+'ipsum' " +
"second should select whitespace+'--' " +
"third should select 'dolor' and following whitespace",
)
@Test
fun `test select multiple outer words starting in whitespace`() {
doTest(
"v3aw",
"Lorem ${c} ipsum --dolor sit amet, consectetur adipiscing elit",
"Lorem${s} ipsum --dolo${c}r${se} sit amet, consectetur adipiscing elit",
"Lorem${s} ipsum --dolor${c} ${se}sit amet, consectetur adipiscing elit",
Mode.VISUAL(SelectionType.CHARACTER_WISE),
)
}

View File

@ -28,8 +28,7 @@ class SearchHelperTest : VimTestCase() {
fun testFindNextWord() {
val text = "first second"
configureByText(text)
val nextWordPosition =
injector.searchHelper.findNextWord(fixture.editor.vim, 0, 1, bigWord = true, spaceWords = false)
val nextWordPosition = injector.searchHelper.findNextWord(fixture.editor.vim, 0, 1, bigWord = true)
kotlin.test.assertEquals(nextWordPosition, text.indexOf("second"))
}
@ -38,7 +37,7 @@ class SearchHelperTest : VimTestCase() {
fun testFindSecondNextWord() {
val text = "first second third"
configureByText(text)
val nextWordPosition = injector.searchHelper.findNextWord(fixture.editor.vim, 0, 2, bigWord = true, false)
val nextWordPosition = injector.searchHelper.findNextWord(fixture.editor.vim, 0, 2, bigWord = true)
kotlin.test.assertEquals(nextWordPosition, text.indexOf("third"))
}
@ -47,7 +46,7 @@ class SearchHelperTest : VimTestCase() {
fun testFindAfterLastWord() {
val text = "first second"
configureByText(text)
val nextWordPosition = injector.searchHelper.findNextWord(fixture.editor.vim, 0, 3, bigWord = true, false)
val nextWordPosition = injector.searchHelper.findNextWord(fixture.editor.vim, 0, 3, bigWord = true)
kotlin.test.assertEquals(nextWordPosition, text.length)
}
@ -57,7 +56,7 @@ class SearchHelperTest : VimTestCase() {
val text = "first second"
configureByText(text)
val previousWordPosition =
injector.searchHelper.findNextWord(fixture.editor.vim, text.indexOf("second"), -1, bigWord = true, false)
injector.searchHelper.findNextWord(fixture.editor.vim, text.indexOf("second"), -1, bigWord = true)
kotlin.test.assertEquals(previousWordPosition, text.indexOf("first"))
}
@ -67,13 +66,7 @@ class SearchHelperTest : VimTestCase() {
val text = "first second third"
configureByText(text)
val previousWordPosition =
injector.searchHelper.findNextWord(
fixture.editor.vim,
text.indexOf("third"),
-2,
bigWord = true,
spaceWords = false,
)
injector.searchHelper.findNextWord(fixture.editor.vim, text.indexOf("third"), -2, bigWord = true)
kotlin.test.assertEquals(previousWordPosition, text.indexOf("first"))
}
@ -83,13 +76,7 @@ class SearchHelperTest : VimTestCase() {
val text = "first second"
configureByText(text)
val previousWordPosition =
injector.searchHelper.findNextWord(
fixture.editor.vim,
text.indexOf("second"),
-3,
bigWord = true,
spaceWords = false,
)
injector.searchHelper.findNextWord(fixture.editor.vim, text.indexOf("second"), -3, bigWord = true)
kotlin.test.assertEquals(previousWordPosition, text.indexOf("first"))
}
@ -98,8 +85,7 @@ class SearchHelperTest : VimTestCase() {
fun testFindPreviousWordWhenCursorOutOfBound() {
val text = "first second"
configureByText(text)
val previousWordPosition =
injector.searchHelper.findNextWord(fixture.editor.vim, text.length, -1, bigWord = true, spaceWords = false)
val previousWordPosition = injector.searchHelper.findNextWord(fixture.editor.vim, text.length, -1, bigWord = true)
kotlin.test.assertEquals(previousWordPosition, text.indexOf("second"))
}

View File

@ -35,7 +35,7 @@ class DeletePrevWordAction : VimActionHandler.SingleExecution() {
val oldText = commandLine.actualText
val motion =
injector.motion.findOffsetOfNextWord(oldText, oldText.length, commandLine.caret.offset, -1, true, editor)
injector.motion.findOffsetOfNextWord(oldText, commandLine.caret.offset, -1, true, editor)
when (motion) {
is Motion.AbsoluteOffset -> {
val newText = oldText.substring(0, motion.offset) + oldText.substring(caretOffset)
@ -48,4 +48,4 @@ class DeletePrevWordAction : VimActionHandler.SingleExecution() {
return true
}
}
}

View File

@ -31,7 +31,7 @@ class MoveToNextWordAction : VimActionHandler.SingleExecution() {
val commandLine = injector.commandLine.getActiveCommandLine() ?: return false
val text = commandLine.actualText
val motion = injector.motion.findOffsetOfNextWord(text, text.length, commandLine.caret.offset, 1, true, editor)
val motion = injector.motion.findOffsetOfNextWord(text, commandLine.caret.offset, 1, true, editor)
when (motion) {
is Motion.AbsoluteOffset -> {
commandLine.caret.offset = motion.offset

View File

@ -31,7 +31,7 @@ class MoveToPreviousWordAction : VimActionHandler.SingleExecution() {
val commandLine = injector.commandLine.getActiveCommandLine() ?: return false
val text = commandLine.actualText
val motion = injector.motion.findOffsetOfNextWord(text, text.length, commandLine.caret.offset, -1, true, editor)
val motion = injector.motion.findOffsetOfNextWord(text, commandLine.caret.offset, -1, true, editor)
when (motion) {
is Motion.AbsoluteOffset -> {
commandLine.caret.offset = motion.offset
@ -41,4 +41,4 @@ class MoveToPreviousWordAction : VimActionHandler.SingleExecution() {
}
return true
}
}
}

View File

@ -102,7 +102,6 @@ interface VimMotionGroup {
* Find the offset of the start of the next/previous word/WORD in some text outside the editor (e.g., command line)
*
* @param text The text to search in
* @param textLength The text length (there is no guarantee that calling [text.length] will be a constant time operation)
* @param searchFrom The buffer offset to start searching from
* @param count The number of words to skip
* @param bigWord If true then find WORD, if false then find word
@ -111,7 +110,6 @@ interface VimMotionGroup {
*/
fun findOffsetOfNextWord(
text: CharSequence,
textLength: Int = text.length,
searchFrom: Int,
count: Int,
bigWord: Boolean,

View File

@ -97,29 +97,20 @@ abstract class VimMotionGroupBase : VimMotionGroup {
* @return position
*/
override fun findOffsetOfNextWord(editor: VimEditor, searchFrom: Int, count: Int, bigWord: Boolean): Motion {
return findOffsetOfNextWord(editor.text(), editor.fileSize().toInt(), searchFrom, count, bigWord, editor)
return findOffsetOfNextWord(editor.text(), searchFrom, count, bigWord, editor)
}
override fun findOffsetOfNextWord(
text: CharSequence,
textLength: Int,
searchFrom: Int,
count: Int,
bigWord: Boolean,
editor: VimEditor,
): Motion {
if ((searchFrom == 0 && count < 0) || (searchFrom >= textLength - 1 && count > 0)) {
if ((searchFrom == 0 && count < 0) || (searchFrom >= text.length - 1 && count > 0)) {
return Motion.Error
}
return (injector.searchHelper.findNextWord(
text,
textLength,
editor,
searchFrom,
count,
bigWord,
false
)).toMotionOrError()
return (injector.searchHelper.findNextWord(text, editor, searchFrom, count, bigWord)).toMotionOrError()
}
override fun getHorizontalMotion(

View File

@ -99,36 +99,35 @@ interface VimSearchHelper {
/**
* Find the next word in the editor's document, from the given starting point
*
* Note that this function can return an out of bounds index when there is no next word!
*
* @param editor The editor's document to search in. Editor is required because word boundaries depend on
* local-to-buffer options
* @param searchFrom The offset in the document to search from
* @param count Return an offset to the [count] word from the starting position. Will search backwards if negative
* @param bigWord Use WORD instead of word boundaries
* @param spaceWords Include whitespace as part of a word, e.g. the difference between `iw` and `aw` motions
* @return The offset of the [count] next word, or `0` or the offset of the end of file if not found
*/
fun findNextWord(editor: VimEditor, searchFrom: Int, count: Int, bigWord: Boolean, spaceWords: Boolean): Int
fun findNextWord(editor: VimEditor, searchFrom: Int, count: Int, bigWord: Boolean): Int
/**
* Find the next word in some text outside the editor (e.g., command line), from the given starting point
*
* Note that this function can return an out of bounds index when there is no next word!
*
* @param text The text to search in
* @param textLength The text length
* @param editor Required because word boundaries depend on local-to-buffer options
* @param searchFrom The offset in the document to search from
* @param count Return an offset to the [count] word from the starting position. Will search backwards if negative
* @param bigWord Use WORD instead of word boundaries
* @param spaceWords Include whitespace as part of a word, e.g. the difference between `iw` and `aw` motions
* @return The offset of the [count] next word, or `0` or the offset of the end of file if not found
*/
fun findNextWord(
text: CharSequence,
textLength: Int,
editor: VimEditor,
searchFrom: Int,
count: Int,
bigWord: Boolean,
spaceWords: Boolean,
): Int
/**

View File

@ -98,25 +98,26 @@ abstract class VimSearchHelperBase : VimSearchHelper {
searchFrom: Int,
count: Int,
bigWord: Boolean,
spaceWords: Boolean,
): Int {
return findNextWord(editor.text(), editor.fileSize().toInt(), editor, searchFrom, count, bigWord, spaceWords)
return findNextWord(editor.text(), editor, searchFrom, count, bigWord)
}
// TODO: Get rid of this overload when rewriting findNextWordOne
override fun findNextWord(
text: CharSequence,
textLength: Int,
editor: VimEditor,
searchFrom: Int,
count: Int,
bigWord: Boolean,
spaceWords: Boolean,
): Int {
val step = if (count >= 0) 1 else -1
var pos = searchFrom
repeat(abs(count)) {
pos = findNextWordOne(text, editor, pos, textLength, step, bigWord, spaceWords)
pos = if (count > 0) {
findNextWordOne(text, editor, pos, bigWord)
} else {
findPreviousWordOne(text, editor, pos, bigWord)
}
if (pos >= text.length) return pos
}
return pos
}
@ -287,7 +288,96 @@ abstract class VimSearchHelperBase : VimSearchHelper {
).map { it.range }
}
/**
* Find the next word from the current starting position, skipping current word and whitespace
*
* Note that this will return an out of bound index if there is no next word! This is necessary to distinguish between
* no next word and the next word being on the last character of the file.
*
* Also remember that two different "word" types can butt up against each other - e.g. KEYWORD followed by PUNCTUATION
*/
private fun findNextWordOne(
chars: CharSequence,
editor: VimEditor,
start: Int,
bigWord: Boolean,
stopAtEndOfLine: Boolean = false,
): Int {
var pos = start
if (pos >= chars.length) return chars.length
val startingCharType = charType(editor, chars[start], bigWord)
// It is important to move first, to properly handle stopping at the end of a line and moving from an empty line
pos++
// If we're on a word, move past the end of it
if (pos < chars.length && startingCharType != CharacterHelper.CharacterType.WHITESPACE) {
pos = skipWhileCharacterType(editor, chars, pos, 1, startingCharType, bigWord)
}
// Skip following whitespace, optionally stopping at the end of the line (on the newline char).
// An empty line is a word, so stop when the offset is at the newline char of an empty line.
while (pos < chars.length && isWhitespace(editor, chars[pos], bigWord)) {
if (isEmptyLine(chars, pos) || (chars[pos] == '\n' && stopAtEndOfLine)) return pos
pos++
}
// We're now on the first character of a word or just past the end of the file
return pos.coerceAtMost(chars.length)
}
/**
* Find the start of the current word, skipping current whitespace
*
* This function will always return an in-bounds index. If there is no previous word (because we're at the start of
* the file), the offset will be `0`, the start of the current word or preceding whitespace.
*/
private fun findPreviousWordOne(
chars: CharSequence,
editor: VimEditor,
start: Int,
bigWord: Boolean,
): Int {
var pos = start
// Always move back one to make sure that we don't get stuck on the start of a word
pos--
// Skip any intermediate whitespace, stopping at an empty line (offset is the newline char of the empty line).
// This will leave us on the last character of the previous word.
while (pos >= 0 && isWhitespace(editor, chars[pos], bigWord)) {
if (isEmptyLine(chars, pos)) return pos
pos--
}
// We're now on a word character, or at the start of the file. Move back until we're past the start of the word,
// then move forward to the start of the word
if (pos >= 0) {
pos = skipWhileCharacterType(editor, chars, pos, -1, charType(editor, chars[pos], bigWord), bigWord) + 1
}
return pos.coerceAtLeast(0)
}
@Suppress("SameParameterValue")
private fun findCharBeforeNextWord(editor: VimEditor, pos: Int, isBig: Boolean, stopAtEndOfLine: Boolean): Int {
val chars = editor.text()
// Find the next word, and take the character before it. If there is no next word, we're at the end of the file, and
// offset will be chars.length. Subtracting one gives us an in-bounds index again
var offset = findNextWordOne(chars, editor, pos, isBig, stopAtEndOfLine) - 1
// Don't back up to the end of a non-empty line
if (chars[offset] == '\n' && !isEmptyLine(chars, offset)) {
offset--
}
return offset
}
// TODO: Remove this once findWordUnderCursor has been properly rewritten
private fun oldFindNextWordOne(
chars: CharSequence,
editor: VimEditor,
pos: Int,
@ -358,7 +448,7 @@ abstract class VimSearchHelperBase : VimSearchHelper {
return res
}
@Suppress("GrazieInspection", "StructuralWrap")
@Suppress("GrazieInspection")
private fun findNextWordEndOne(
chars: CharSequence,
editor: VimEditor,
@ -1440,10 +1530,10 @@ abstract class VimSearchHelperBase : VimSearchHelper {
// TODO: This could be simplified to move backwards until char type changes
if ((!onWordStart && !(onSpace && isOuter)) || hasSelection || (count > 1 && dir == -1)) {
start = if (dir == 1) {
findNextWord(editor, pos, -1, isBig, !isOuter)
oldFindNextWordOne(editor.text(), editor, pos, editor.text().length, -1, isBig, spaceWords = !isOuter)
} else {
val c = -(count - if (onWordStart && !hasSelection) 1 else 0)
findNextWord(editor, pos, c, isBig, !isOuter)
oldFindNextWordOne(editor.text(), editor, pos, editor.text().length, c, isBig, spaceWords = !isOuter)
}
start = editor.normalizeOffset(start, false)
}
@ -1460,6 +1550,7 @@ abstract class VimSearchHelperBase : VimSearchHelper {
// TODO: Figure out the logic of this going backwards
if (dir == 1) {
var count = count
var shouldEndOnWhitespace = false
// Selecting word/WORDs (forwards):
// If there's no selection, we need to calculate the first range:
@ -1508,16 +1599,18 @@ abstract class VimSearchHelperBase : VimSearchHelper {
// We're on a word, move to the end, and include following whitespace by moving to the character before the
// next word
isOuter && !onSpace -> // "${s}word ${se}word"
findNextWord(chars, chars.length, editor, start, 1, isBig, spaceWords = true) - 1
isOuter && !onSpace -> { // "${s}word ${se}word"
shouldEndOnWhitespace = true
findCharBeforeNextWord(editor, start, isBig, stopAtEndOfLine = true)
}
// We're on a word, move to the end, not including trailing whitespace
!isOuter && !onSpace -> // "${s}word${se} word"
findNextWordEnd(editor, start, 1, isBig, stopOnEmptyLine = true, allowMoveFromWordEnd = false)
// We're on preceding whitespace, move to the character before the next word
else /* !isOuter && onSpace */ -> // "${s} ${se}word"
findNextWord(chars, chars.length, editor, start, 1, isBig, spaceWords = true) - 1
else /* !isOuter && onSpace */ -> // "${s} ${se}word"
findCharBeforeNextWord(editor, start, isBig, stopAtEndOfLine = true)
}
count--
@ -1585,7 +1678,7 @@ abstract class VimSearchHelperBase : VimSearchHelper {
}
else {
// Move to one before start of next word (skips following whitespace)
findNextWord(chars, chars.length, editor, end, 1, isBig, spaceWords = true) - 1
findCharBeforeNextWord(editor, end, isBig, stopAtEndOfLine = true)
}
} else {
@ -1657,6 +1750,23 @@ abstract class VimSearchHelperBase : VimSearchHelper {
}
}
}
if (isOuter && shouldEndOnWhitespace && start > 0
&& !isWhitespace(editor, chars[end], isBig)
&& !isWhitespace(editor, chars[start], isBig)) {
// Outer word objects normally include following whitespace. But if there's no following whitespace to include,
// we should extend the range to include preceding whitespace. However, Vim doesn't select whitespace at the
// start of a line
var offset = start - 1
while (offset >= 0 && chars[offset] != '\n' && isWhitespace(editor, chars[offset], isBig)) {
offset--
}
if (offset > 0 && chars[offset] != '\n') start = offset + 1
}
if (start == end && chars[start] == '\n') end++
return TextRange(start, end + 1)
}
else if (!onWordEnd || hasSelection || (count > 1 && dir == 1) || (onSpace && isOuter)) {
end = if (dir == 1) {