override fun findWordObject()

in vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimSearchHelperBase.kt [1607:1792]


  override fun findWordObject(
    editor: VimEditor,
    caret: ImmutableVimCaret,
    count: Int,
    isOuter: Boolean,
    isBig: Boolean,
  ): TextRange {
    // Note: for more detailed comments with examples, check git history!

    // Ignore single width selection. We still move both back and forwards with a single width selection
    val hasSelection = editor.inVisualMode && caret.vimSelectionStart != caret.offset
    val direction =
      if (hasSelection && caret.vimSelectionStart > caret.offset) Direction.BACKWARDS else Direction.FORWARDS

    val pos = caret.offset
    val chars = editor.text()
    if (chars.isEmpty()) return TextRange(0, 0)
    if (chars.length <= pos) return TextRange(chars.length - 1, chars.length - 1)

    var start = pos
    var end = start
    var count = count
    var shouldEndOnWhitespace = false

    // If there's no selection, calculate the initial range by moving back to the start of the current character type
    // on the current line (word/WORD or whitespace). Then move forward:
    // * For inner objects, move to the end of the current word or whitespace block (or line).
    // * For outer objects, whitespace is included. Move to the end of the current word (or line) and following
    //   whitespace (if any), or move to the end of the current whitespace (possibly wrapping) and following word.
    // Note that the flag for selection is only true if the selection is greater than a single char. Also remember
    // that an empty line is a word and there are multiple word types not necessarily separated by whitespace.
    if (!hasSelection) {
      val startingCharacterType = charType(editor, chars[pos], isBig)
      start = pos
      if (!isEmptyLine(chars, start)) {
        while (start >= 0 && chars[start] != '\n' && charType(editor, chars[start], isBig) == startingCharacterType) {
          start--
        }
        start++
      }

      end = if ((!isOuter && isWhitespace(editor, chars[start], isBig))
        || (isOuter && !isWhitespace(editor, chars[start], isBig))
      ) {
        // * Inner object, on whitespace. Skip forward to the end of the current whitespace, just before the next
        //   word or end of line (no wrapping). This will always move us forward one character, so it's always safe to
        //   move one character back. If we're moving on to an empty line (newline is whitespace!) this will move one
        //   character forward and then one character back. I.e. `viw` on an empty line only selects the line!
        // * Outer object, on word. Skip the current word and include any following whitespace. We know this isn't an
        //   empty line and that we'll stop at the end of the current line, so it's always safe to move back on char.
        if (isOuter) {
          // Outer objects should include following whitespace. But if there isn't any, we should walk back and
          // include any preceding whitespace.
          shouldEndOnWhitespace = true
        }

        val offset = findNextWordOne(chars, editor, start, isBig, stopAtEndOfLine = true)
        skipOneCharacterBack(offset)
      } else {
        // * Inner object, on word. Move to the end of the current word, do not bother with whitespace.
        // * Outer object, on whitespace. Include whitespace and the following word by moving to the end of the next
        //   word/WORD. Newlines are considered whitespace and so can wrap. Make sure that if we are currently at the
        //   end of a word (because we advanced above) that we do not advance to the end of the subsequent word.
        findNextWordEndOne(chars, editor, start, isBig, stopOnEmptyLine = true, allowMoveFromWordEnd = false)
      }

      count--
    }

    // We have an initial selection/range. Now loop over what's left of count.
    // The actions are very similar, but there is subtly different handling of newlines and empty lines.
    // Note that we can't make any assumptions about the start/end positions. If there was no initial selection, we know
    // that start is at the beginning of the initial character type and end will be either at the end of a word, end of
    // whitespace or end of line. But if there was an initial selection, it all depends on what the user selected.
    repeat(count) {
      when (direction) {
        Direction.FORWARDS -> {
          // Move forward (and skip end of line char) so we know if we need to move to the current or next word.
          // If we're at the end of a word, the next character will be a different character type/whitespace.
          // If we're in the middle of a word, the next character will still be the current word.
          end++
          if (end < chars.length && chars[end] == '\n' && !isEmptyLine(chars, end)) {
            end++
          }

          if (end >= chars.length) {
            end--
            return@repeat
          }

          end = if ((!isOuter && isWhitespace(editor, chars[end], isBig))
            || (isOuter && !isWhitespace(editor, chars[end], isBig))
          ) {
            // * Inner object, on whitespace. Whitespace is treated separately and included in the count. Skip the
            //   current whitespace, up to the character before the next word, or the end of the line.
            //   For a non-empty line, this will always move forwards, so it is always safe to move one char back.
            //   For empty lines, things are more complex and different to the behaviour above when we set the initial
            //   range. In that case, we advance while trying to get to the next word, encounter an empty line, stop,
            //   and then we always move one character back. This can put us back to the original offset (try `viw` on
            //   an empty line - the caret doesn't move).
            //   Vim does things differently if there's an existing selection (i.e., this scenario) and we're moving on
            //   to an empty line. The algorithm advances early (see above), and this skips us past a newline char and
            //   on to an empty line. We then try to find the next word, which automatically advances a character, on to
            //   the start of the next line. And now Vim does NOT go back one character, because that would put us at
            //   the newline char of the previous line.
            //   You can see this behaviour with `v2iw` on empty lines. Vim selects the first line while initialising
            //   the range, and then advances 2 lines while handling the second iteration. Similarly, `v3iw` selects 5
            //   lines. Interestingly, because we're now at the start of another line, the now-current line might not be
            //   empty. That means Vim now has a "word" text object that selects just the first character in a line!
            //   And because we've figured out this difference in handling empty lines, we match Vim's quirky behaviour!
            //   See vim/vim#16514
            // * Outer object, on a word character. Move to the end of the current word including following whitespace.
            //   This is the same as moving to the character before the next word. Also stop at the end of the current
            //   line. We know this isn't an empty line, so we will never wrap and will always move forward at least one
            //   character. It is therefore always safe to move back one character, without reaching the start of line.
            val offset = findNextWordOne(chars, editor, end, isBig, stopAtEndOfLine = true)
            skipOneCharacterBackOnCurrentLine(chars, offset)
          } else {
            // * Inner object, on a word character. Move to the end of the current word. This does not look at
            //   whitespace, and remains on the current line.
            // * Outer object, on whitespace. Move to the end of the next word, which will skip the current whitespace.
            //   Newline characters are whitespace, so this can wrap, although it will stop at an empty line. Make sure
            //   that if we are currently at the end of a word (because we advanced above) that we do not advance to the
            //   end of the subsequent word.
            findNextWordEndOne(chars, editor, end, isBig, stopOnEmptyLine = true, allowMoveFromWordEnd = false)
          }
        }

        Direction.BACKWARDS -> {
          // If direction is backwards, then `end` is already correctly positioned, and we need to move `start`.
          // As above, move back early so we handle word boundaries correctly
          start--
          if (start > 0 && chars[start] == '\n' && !isEmptyLine(chars, start)) {
            start--
          }

          if (start < 0) {
            start++
            return@repeat
          }

          start = if ((!isOuter && isWhitespace(editor, chars[start], isBig))
            || (isOuter && !isWhitespace(editor, chars[start], isBig))
          ) {
            // * Inner object, on whitespace. Move to start of whitespace, by moving to the end of the previous word and
            //   then moving forward. Newlines are whitespace, but we stop at the start of the line.
            // * Outer object, on word. Move to start of current word, then include and preceding whitespace, but stop
            //   at the start of line. This is the same as one past the end of the previous word.
            // Note that we actually stop at the end of the previous line, but the `+1` fixes things up.
            val offset = findPreviousWordEndOne(chars, editor, start, isBig, stopAtEndOfPreviousLine = true) + 1
            if (chars[offset] == '\n') offset + 1 else offset
          } else {
            // * Inner object, on word. Move back to the start of the current word. Ignore whitespace.
            // * Outer object, on whitespace. Skip the current whitespace and move to the start of the previous word.
            //   Newlines are whitespace, so this will wrap at the start of the line and move to the start of the last
            //   word on the previous line, skipping trailing whitespace.
            findPreviousWordOne(chars, editor, start, isBig, allowMoveFromWordStart = false)
          }
        }
      }
    }

    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
    }

    // TODO: Remove this when IdeaVim supports selecting the new line character
    // A selection with start == end is perfectly valid, and will select a single character. However, IdeaVim
    // unnecessarily prevents selecting the new line character at the end of a line. If the selection is just that new
    // line character, then nothing is selected (we end up with a selection with range start==endInclusive, rather than
    // start==endExclusive). This little hack makes sure that `viw` will (mostly) work on a single empty line
    if (start == end && chars[start] == '\n') end++

    // Text range's end offset is exclusive
    return TextRange(start, end + 1)
  }