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

Revert "Revert changes to SearchGroup"

This reverts commit 00ccddf8cf.
This commit is contained in:
filipp 2024-03-03 22:18:14 +02:00 committed by Alex Pláte
parent bfcf706ca7
commit 3f65d1d99a
17 changed files with 853 additions and 156 deletions
src
main/java/com/maddyhome/idea/vim
test/java/org/jetbrains/plugins/ideavim/action/motion
testFixtures/kotlin/org/jetbrains/plugins/ideavim
tests/java-tests/src/test/kotlin/org/jetbrains/plugins/ideavim/action/motion/updown
vim-engine/src
main/kotlin/com/maddyhome/idea/vim
test/kotlin/com/maddyhome/idea/vim/groups

View File

@ -0,0 +1,61 @@
/*
* Copyright 2003-2024 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.group
import com.intellij.lang.CodeDocumentationAwareCommenter
import com.intellij.lang.LanguageCommenters
import com.intellij.openapi.components.Service
import com.intellij.psi.PsiComment
import com.intellij.psi.util.PsiTreeUtil
import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.api.VimPsiService
import com.maddyhome.idea.vim.common.TextRange
import com.maddyhome.idea.vim.helper.PsiHelper
import com.maddyhome.idea.vim.newapi.ij
import com.maddyhome.idea.vim.newapi.vim
@Service
public class IjVimPsiService: VimPsiService {
override fun getCommentAtPos(editor: VimEditor, pos: Int): Pair<TextRange, Pair<String, String>?>? {
val psiFile = PsiHelper.getFile(editor.ij) ?: return null
val psiElement = psiFile.findElementAt(pos) ?: return null
val language = psiElement.language
val commenter = LanguageCommenters.INSTANCE.forLanguage(language)
val psiComment = PsiTreeUtil.getParentOfType(psiElement, PsiComment::class.java, false) ?: return null
val commentText = psiComment.text
val blockCommentPrefix = commenter.blockCommentPrefix
val blockCommentSuffix = commenter.blockCommentSuffix
val docCommentPrefix = (commenter as? CodeDocumentationAwareCommenter)?.documentationCommentPrefix
val docCommentSuffix = (commenter as? CodeDocumentationAwareCommenter)?.documentationCommentSuffix
val prefixToSuffix: Pair<String, String>? =
if (docCommentPrefix != null && docCommentSuffix != null && commentText.startsWith(docCommentPrefix) && commentText.endsWith(docCommentSuffix)) {
docCommentPrefix to docCommentSuffix
}
else if (blockCommentPrefix != null && blockCommentSuffix != null && commentText.startsWith(blockCommentPrefix) && commentText.endsWith(blockCommentSuffix)) {
blockCommentPrefix to blockCommentSuffix
}
else {
null
}
return Pair(psiComment.textRange.vim, prefixToSuffix)
}
override fun getDoubleQuotedString(editor: VimEditor, pos: Int, isInner: Boolean): TextRange? {
// TODO[ideavim] It wasn't implemented before, but implementing it will significantly improve % motion
return getDoubleQuotesRangeNoPSI(editor.text(), pos, isInner)
}
override fun getSingleQuotedString(editor: VimEditor, pos: Int, isInner: Boolean): TextRange? {
// TODO[ideavim] It wasn't implemented before, but implementing it will significantly improve % motion
return getSingleQuotesRangeNoPSI(editor.text(), pos, isInner)
}
}

View File

@ -105,7 +105,7 @@ internal class MotionGroup : VimMotionGroupBase() {
VimPlugin.getFile().selectEditor(project, file)
override fun moveCaretToMatchingPair(editor: VimEditor, caret: ImmutableVimCaret): Motion {
return SearchHelper.findMatchingPairOnCurrentLine(editor.ij, caret.ij).toMotionOrError()
return findMatchingPairOnCurrentLine(editor, caret)?.toMotionOrError() ?: Motion.Error
}
override fun moveCaretToFirstDisplayLine(

View File

@ -632,113 +632,6 @@ public class SearchHelper {
return new TextRange(bstart, bend + 1);
}
private static int findMatchingBlockCommentPair(@NotNull PsiComment comment,
int pos,
@Nullable String prefix,
@Nullable String suffix) {
if (prefix != null && suffix != null) {
// TODO: Try to get rid of `getText()` because it takes a lot of time to calculate the string
final String commentText = comment.getText();
if (commentText.startsWith(prefix) && commentText.endsWith(suffix)) {
final int endOffset = comment.getTextOffset() + comment.getTextLength();
if (pos < comment.getTextOffset() + prefix.length()) {
return endOffset;
}
else if (pos >= endOffset - suffix.length()) {
return comment.getTextOffset();
}
}
}
return -1;
}
private static int findMatchingBlockCommentPair(@NotNull PsiElement element, int pos) {
final Language language = element.getLanguage();
final Commenter commenter = LanguageCommenters.INSTANCE.forLanguage(language);
final PsiComment comment = PsiTreeUtil.getParentOfType(element, PsiComment.class, false);
if (comment != null) {
final int ret = findMatchingBlockCommentPair(comment, pos, commenter.getBlockCommentPrefix(),
commenter.getBlockCommentSuffix());
if (ret >= 0) {
return ret;
}
if (commenter instanceof CodeDocumentationAwareCommenter docCommenter) {
return findMatchingBlockCommentPair(comment, pos, docCommenter.getDocumentationCommentPrefix(),
docCommenter.getDocumentationCommentSuffix());
}
}
return -1;
}
/**
* This looks on the current line, starting at the cursor position for one of {, }, (, ), [, or ]. It then searches
* forward or backward, as appropriate for the associated match pair. String in double quotes are skipped over.
* Single characters in single quotes are skipped too.
*
* @param editor The editor to search in
* @return The offset within the editor of the found character or -1 if no match was found or none of the characters
* were found on the remainder of the current line.
*/
public static int findMatchingPairOnCurrentLine(@NotNull Editor editor, @NotNull Caret caret) {
int pos = caret.getOffset();
final int commentPos = findMatchingComment(editor, pos);
if (commentPos >= 0) {
return commentPos;
}
int line = caret.getLogicalPosition().line;
final IjVimEditor vimEditor = new IjVimEditor(editor);
int end = EngineEditorHelperKt.getLineEndOffset(vimEditor, line, true);
// To handle the case where visual mode allows the user to go past the end of the line,
// which will prevent loc from finding a pairable character below
if (pos > 0 && pos == end) {
pos = end - 1;
}
final String pairChars = parseMatchPairsOption(vimEditor);
CharSequence chars = editor.getDocument().getCharsSequence();
int loc = -1;
// Search the remainder of the current line for one of the candidate characters
while (pos < end) {
loc = pairChars.indexOf(chars.charAt(pos));
if (loc >= 0) {
break;
}
pos++;
}
int res = -1;
// If we found one ...
if (loc >= 0) {
// What direction should we go now (-1 is backward, 1 is forward)
Direction dir = loc % 2 == 0 ? Direction.FORWARDS : Direction.BACKWARDS;
// Which character did we find and which should we now search for
char found = pairChars.charAt(loc);
char match = pairChars.charAt(loc + dir.toInt());
res = findBlockLocation(chars, found, match, dir, pos, 1, true);
}
return res;
}
/**
* If on the start/end of a block comment, jump to the matching of that comment, or vice versa.
*/
private static int findMatchingComment(@NotNull Editor editor, int pos) {
final PsiFile psiFile = PsiHelper.getFile(editor);
if (psiFile != null) {
final PsiElement element = psiFile.findElementAt(pos);
if (element != null) {
return findMatchingBlockCommentPair(element, pos);
}
}
return -1;
}
private static int findBlockLocation(@NotNull CharSequence chars,
char found,
char match,

View File

@ -500,3 +500,6 @@ public val Editor.vim: VimEditor
get() = IjVimEditor(this)
public val VimEditor.ij: Editor
get() = (this as IjVimEditor).editor
public val com.intellij.openapi.util.TextRange.vim: TextRange
get() = TextRange(this.startOffset, this.endOffset)

View File

@ -42,6 +42,7 @@ import com.maddyhome.idea.vim.api.VimMessages
import com.maddyhome.idea.vim.api.VimMotionGroup
import com.maddyhome.idea.vim.api.VimOptionGroup
import com.maddyhome.idea.vim.api.VimProcessGroup
import com.maddyhome.idea.vim.api.VimPsiService
import com.maddyhome.idea.vim.api.VimRegexpService
import com.maddyhome.idea.vim.api.VimScrollGroup
import com.maddyhome.idea.vim.api.VimSearchGroup
@ -65,6 +66,7 @@ import com.maddyhome.idea.vim.group.FileGroup
import com.maddyhome.idea.vim.group.GlobalIjOptions
import com.maddyhome.idea.vim.group.HistoryGroup
import com.maddyhome.idea.vim.group.IjVimOptionGroup
import com.maddyhome.idea.vim.group.IjVimPsiService
import com.maddyhome.idea.vim.group.MacroGroup
import com.maddyhome.idea.vim.group.MotionGroup
import com.maddyhome.idea.vim.group.SearchGroup
@ -147,6 +149,8 @@ internal class IjVimInjector : VimInjectorBase() {
get() = service<MacroGroup>()
override val undo: VimUndoRedo
get() = service<UndoRedoHelper>()
override val psiService: VimPsiService
get() = service<IjVimPsiService>()
override val commandLineHelper: VimCommandLineHelper
get() = service<CommandLineHelper>()
override val nativeActionManager: NativeActionManager

View File

@ -84,22 +84,6 @@ internal class IjVimSearchHelper : VimSearchHelperBase() {
return PsiHelper.findMethodStart(editor.ij, caret.ij.offset, count)
}
override fun findUnmatchedBlock(editor: VimEditor, caret: ImmutableVimCaret, type: Char, count: Int): Int {
val chars: CharSequence = editor.ij.document.charsSequence
var pos: Int = caret.ij.offset
val loc = BLOCK_CHARS.indexOf(type)
// What direction should we go now (-1 is backward, 1 is forward)
val dir = if (loc % 2 == 0) Direction.BACKWARDS else Direction.FORWARDS
// Which character did we find and which should we now search for
val match = BLOCK_CHARS[loc]
val found = BLOCK_CHARS[loc - dir.toInt()]
if (pos < chars.length && chars[pos] == type) {
pos += dir.toInt()
}
return findBlockLocation(chars, found, match, dir, pos, count)
}
private fun findBlockLocation(
chars: CharSequence,
found: Char,

View File

@ -98,15 +98,6 @@ class MotionUnmatchedBraceOpenActionTest : VimTestCase() {
)
}
@VimBehaviorDiffers(
originalVimAfter = """
class Xxx $c{
int main() {
}
}
""",
)
@Test
fun `test go to next next bracket with great count`() {
doTest(
@ -119,9 +110,9 @@ class MotionUnmatchedBraceOpenActionTest : VimTestCase() {
}
""".trimIndent(),
"""
class Xxx {
class Xxx $c{
int main() {
$c
}
}
""".trimIndent(),

View File

@ -8,10 +8,12 @@
package org.jetbrains.plugins.ideavim.action.motion.updown
import com.intellij.idea.TestFor
import com.maddyhome.idea.vim.state.mode.Mode
import org.jetbrains.plugins.ideavim.SkipNeovimReason
import org.jetbrains.plugins.ideavim.TestWithoutNeovim
import org.jetbrains.plugins.ideavim.VimTestCase
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Test
/**
@ -143,6 +145,7 @@ class MotionPercentOrMatchActionTest : VimTestCase() {
}
@Test
@Disabled("It will work after implementing all of the methods in VimPsiService")
fun `test motion outside text`() {
doTest(
"%",
@ -207,41 +210,45 @@ class MotionPercentOrMatchActionTest : VimTestCase() {
}
@Test
@TestWithoutNeovim(SkipNeovimReason.BUG_IN_NEOVIM)
fun `test motion in text with escape (outer forward)`() {
doTest(
"%",
""" debugPrint$c(\(var)) """,
""" debugPrint(\(var)$c) """,
""" debugPrint(\(var$c)) """,
Mode.NORMAL(),
)
}
@Test
@TestWithoutNeovim(SkipNeovimReason.BUG_IN_NEOVIM)
fun `test motion in text with escape (outer backward)`() {
doTest(
"%",
""" debugPrint(\(var)$c) """,
""" debugPrint$c(\(var)) """,
""" debugPrint(\(var)$c) """,
Mode.NORMAL(),
)
}
@Test
@TestWithoutNeovim(SkipNeovimReason.BUG_IN_NEOVIM)
fun `test motion in text with escape (inner forward)`() {
doTest(
"%",
""" debugPrint(\$c(var)) """,
""" debugPrint(\(var$c)) """,
""" debugPrint(\$c(var)) """,
Mode.NORMAL(),
)
}
@Test
@TestWithoutNeovim(SkipNeovimReason.BUG_IN_NEOVIM)
fun `test motion in text with escape (inner backward)`() {
doTest(
"%",
""" debugPrint(\$c(var)) """,
""" debugPrint(\(var$c)) """,
""" debugPrint(\$c(var)) """,
Mode.NORMAL(),
)
}
@ -332,4 +339,28 @@ class MotionPercentOrMatchActionTest : VimTestCase() {
)
assertOffset(10)
}
@Test
@TestFor(issues = ["VIM-3294"])
fun `test matching with braces inside of string`() {
configureByText("""
$c("("")")
""".trimIndent())
typeText("%")
assertState("""
("("")"$c)
""".trimIndent())
}
@Test
@TestFor(issues = ["VIM-3294"])
fun `test matching with braces inside of string 2`() {
configureByText("""
("("")"$c)
""".trimIndent())
typeText("%")
assertState("""
$c("("")")
""".trimIndent())
}
}

View File

@ -245,6 +245,9 @@ enum class SkipNeovimReason {
GUARDED_BLOCKS,
CTRL_CODES,
BUG_IN_NEOVIM,
PSI,
}
fun LogicalPosition.toVimCoords(): VimCoords {

View File

@ -8,6 +8,7 @@
package org.jetbrains.plugins.ideavim.action.motion.updown
import com.intellij.idea.TestFor
import org.jetbrains.plugins.ideavim.SkipNeovimReason
import org.jetbrains.plugins.ideavim.TestWithoutNeovim
import org.jetbrains.plugins.ideavim.VimJavaTestCase
@ -66,4 +67,98 @@ class MotionPercentOrMatchActionJavaTest : VimJavaTestCase() {
typeText("%")
assertState("/* foo $c */")
}
@Test
@TestFor(issues = ["VIM-1399"])
@TestWithoutNeovim(SkipNeovimReason.PSI)
fun `test percent ignores brace inside comment`() {
configureByJavaText("""
protected TokenStream normalize(String fieldName, TokenStream in) {
TokenStream result = new EmptyTokenFilter(in); /* $c{
* some text
*/
result = new LowerCaseFilter(result);
return result;
}
""".trimIndent())
typeText("%")
assertState("""
protected TokenStream normalize(String fieldName, TokenStream in) {
TokenStream result = new EmptyTokenFilter(in); /* $c{
* some text
*/
result = new LowerCaseFilter(result);
return result;
}
""".trimIndent())
}
@Test
@TestFor(issues = ["VIM-1399"])
@TestWithoutNeovim(SkipNeovimReason.PSI)
fun `test percent doesnt match brace inside comment`() {
configureByJavaText("""
protected TokenStream normalize(String fieldName, TokenStream in) $c{
TokenStream result = new EmptyTokenFilter(in); /* {
* some text
*/
result = new LowerCaseFilter(result);
return result;
}
""".trimIndent())
typeText("%")
assertState("""
protected TokenStream normalize(String fieldName, TokenStream in) {
TokenStream result = new EmptyTokenFilter(in); /* {
* some text
*/
result = new LowerCaseFilter(result);
return result;
$c}
""".trimIndent())
}
@Test
@TestWithoutNeovim(SkipNeovimReason.PSI)
fun `test matching works with a sequence of single-line comments`() {
configureByJavaText("""
protected TokenStream normalize(String fieldName, TokenStream in) {
// $c{
// result = new LowerCaseFilter(result);
// }
return result;
}
""".trimIndent())
typeText("%")
assertState("""
protected TokenStream normalize(String fieldName, TokenStream in) {
// {
// result = new LowerCaseFilter(result);
// $c}
return result;
}
""".trimIndent())
}
@Test
@TestWithoutNeovim(SkipNeovimReason.PSI)
fun `test matching doesn't work if a sequence of single-line comments is broken`() {
configureByJavaText("""
protected TokenStream normalize(String fieldName, TokenStream in) {
// $c{
result = new LowerCaseFilter(result);
// }
return result;
}
""".trimIndent())
typeText("%")
assertState("""
protected TokenStream normalize(String fieldName, TokenStream in) {
// $c{
result = new LowerCaseFilter(result);
// }
return result;
}
""".trimIndent())
}
}

View File

@ -12,12 +12,12 @@ import com.intellij.vim.annotations.Mode
import com.maddyhome.idea.vim.api.ExecutionContext
import com.maddyhome.idea.vim.api.ImmutableVimCaret
import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.api.normalizeOffset
import com.maddyhome.idea.vim.command.Argument
import com.maddyhome.idea.vim.command.CommandFlags
import com.maddyhome.idea.vim.command.MotionType
import com.maddyhome.idea.vim.command.OperatorArguments
import com.maddyhome.idea.vim.group.findUnmatchedBlock
import com.maddyhome.idea.vim.handler.Motion
import com.maddyhome.idea.vim.handler.MotionActionHandler
import com.maddyhome.idea.vim.handler.toMotionOrError
@ -36,8 +36,7 @@ public sealed class MotionUnmatchedAction(private val motionChar: Char) : Motion
argument: Argument?,
operatorArguments: OperatorArguments,
): Motion {
return moveCaretToUnmatchedBlock(editor, caret, operatorArguments.count1, motionChar)
.toMotionOrError()
return moveCaretToUnmatchedBlock(editor, caret, operatorArguments.count1, motionChar)?.toMotionOrError() ?: Motion.Error
}
}
@ -53,14 +52,11 @@ public class MotionUnmatchedParenCloseAction : MotionUnmatchedAction(')')
@CommandOrMotion(keys = ["[("], modes = [Mode.NORMAL, Mode.VISUAL, Mode.OP_PENDING])
public class MotionUnmatchedParenOpenAction : MotionUnmatchedAction('(')
private fun moveCaretToUnmatchedBlock(editor: VimEditor, caret: ImmutableVimCaret, count: Int, type: Char): Int {
private fun moveCaretToUnmatchedBlock(editor: VimEditor, caret: ImmutableVimCaret, count: Int, type: Char): Int? {
return if (editor.currentCaret().offset.point == 0 && count < 0 || editor.currentCaret().offset.point >= editor.fileSize() - 1 && count > 0) {
-1
null
} else {
var res = injector.searchHelper.findUnmatchedBlock(editor, caret, type, count)
if (res != -1) {
res = editor.normalizeOffset(res, false)
}
res
val res = findUnmatchedBlock(editor, caret.offset.point, type, count) ?: return null
return editor.normalizeOffset(res, false)
}
}

View File

@ -167,6 +167,8 @@ public interface VimInjector {
// !! in progress
public val undo: VimUndoRedo
public val psiService: VimPsiService
// !! in progress
public val commandLineHelper: VimCommandLineHelper

View File

@ -0,0 +1,36 @@
/*
* Copyright 2003-2024 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.api
import com.maddyhome.idea.vim.common.TextRange
public interface VimPsiService {
/**
* @return triple of comment range, comment prefix, comment suffix or null if there is no comment at the given position
*/
public fun getCommentAtPos(editor: VimEditor, pos: Int): Pair<TextRange, Pair<String, String>?>?
/**
* @param isInner A flag indicating whether the start and end quote characters should be considered part of the string:
* - If set to true, only the text between the quote characters is included in the range.
* - If set to false, the quote characters at the boundaries are included as part of the string range.
*
* NOTE: Regardless of the [isInner] value, a TextRange will be returned if the caret is positioned on a quote character.
*/
public fun getDoubleQuotedString(editor: VimEditor, pos: Int, isInner: Boolean): TextRange?
/**
* @param isInner A flag indicating whether the start and end quote characters should be considered part of the string:
* - If set to true, only the text between the quote characters is included in the range.
* - If set to false, the quote characters at the boundaries are included as part of the string range.
*
* NOTE: Regardless of the [isInner] value, a TextRange will be returned if the caret is positioned on a quote character.
*/
public fun getSingleQuotedString(editor: VimEditor, pos: Int, isInner: Boolean): TextRange?
}

View File

@ -97,13 +97,6 @@ public interface VimSearchHelper {
count: Int,
): Int
public fun findUnmatchedBlock(
editor: VimEditor,
caret: ImmutableVimCaret,
type: Char,
count: Int,
): Int
/**
* Find the next word in the editor's document, from the given starting point
*

View File

@ -0,0 +1,381 @@
/*
* Copyright 2003-2024 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.group
import com.maddyhome.idea.vim.api.ImmutableVimCaret
import com.maddyhome.idea.vim.api.VimEditor
import com.maddyhome.idea.vim.api.injector
import com.maddyhome.idea.vim.api.options
import com.maddyhome.idea.vim.common.Direction
import com.maddyhome.idea.vim.common.TextRange
public const val BLOCK_CHARS: String = "{}()[]<>"
public fun isInsideComment(editor: VimEditor, pos: Int): Boolean {
return injector.psiService.getCommentAtPos(editor, pos) != null
}
/**
* Determines whether the specified caret position is within the bounds of a single-quoted string in the editor
* (uses PSI when possible)
*
* @param isInner A flag indicating the behavior regarding quote characters themselves:
* - If set to true, the positions of the quote characters are not considered part of the string.
* In this case, the caret must be between the quotes to be considered inside the string.
* - If set to false, the positions of the quote characters enclosing a string are considered part of the string.
* This means the caret is considered inside the string even if it's directly on one of the quotes.
*/
public fun isInsideSingleQuotes(editor: VimEditor, pos: Int, isInner: Boolean): Boolean {
val range = injector.psiService.getSingleQuotedString(editor, pos, isInner) ?: return false
return pos in range
}
/**
* Determines whether the specified caret position is within the bounds of a double-quoted string in the editor
* (uses PSI when possible)
*
* @param isInner A flag indicating the behavior regarding quote characters themselves:
* - If set to true, the positions of the quote characters are not considered part of the string.
* In this case, the caret must be between the quotes to be considered inside the string.
* - If set to false, the positions of the quote characters enclosing a string are considered part of the string.
* This means the caret is considered inside the string even if it's directly on one of the quotes.
*/
public fun isInsideDoubleQuotes(editor: VimEditor, pos: Int, isInner: Boolean): Boolean {
val range = injector.psiService.getDoubleQuotedString(editor, pos, isInner) ?: return false
return pos in range
}
/**
* Determines whether the specified caret position is located within the bounds of a string in the document.
* A "string" is defined as any sequence of text enclosed in single or double quotes.
* (uses PSI when possible)
*
* @param isInner Flag indicating if the quotes themselves are treated as inside the string:
* - If true, the caret needs to be between the quote marks to be within the string.
* - If false, caret positions on the quote marks are counted as within the string.
*/
public fun isInsideString(editor: VimEditor, pos: Int, isInner: Boolean): Boolean {
val range = getStringAtPos(editor, pos, isInner) ?: return false
return pos in range
}
/**
* Retrieves the range of text that represents a "string" at a given caret position within the editor's document.
* A "string" is defined as any sequence of text enclosed in single or double quotes.
* (uses PSI when possible)
*
* @param isInner A flag indicating whether the start and end quote characters should be considered part of the string:
* - If set to true, only the text between the quote characters is included in the range.
* - If set to false, the quote characters at the boundaries are included as part of the string range.
*
* NOTE: Regardless of the [isInner] value, a TextRange will be returned if the caret is positioned on a quote character.
*/
public fun getStringAtPos(editor: VimEditor, pos: Int, isInner: Boolean): TextRange? {
return injector.psiService.getDoubleQuotedString(editor, pos, isInner) ?: injector.psiService.getSingleQuotedString(editor, pos, isInner)
}
/**
* This method emulates the Vim '%' command for comments.
* If the caret is positioned over a comment boundary, this method returns the position of the opposing boundary.
*/
public fun getCommentsOppositeBoundary(editor: VimEditor, pos: Int): Int? {
val (range, prefixToSuffix) = injector.psiService.getCommentAtPos(editor, pos) ?: return null
if (prefixToSuffix == null) return null
return if (pos < range.startOffset + prefixToSuffix.first.length) {
range.endOffset - 1
} else if (pos >= range.endOffset - prefixToSuffix.second.length) {
range.startOffset
} else {
null
}
}
/**
* This looks on the current line, starting at the cursor position for one of {, }, (, ), [, or ]. It then searches
* forward or backward, as appropriate for the associated match pair. String in double quotes are skipped over.
* Single characters in single quotes are skipped too.
*
* @param editor The editor to search in
* @return The offset within the editor of the found character or -1 if no match was found or none of the characters
* were found on the remainder of the current line.
*/
// TODO [vakhitov] it would be better to make this search PSI-aware and skip chars in strings and comments
public fun findMatchingPairOnCurrentLine(editor: VimEditor, caret: ImmutableVimCaret): Int? {
var pos = caret.offset.point
val commentPos = getCommentsOppositeBoundary(editor, pos)
if (commentPos != null) {
return commentPos
}
val lineEnd = editor.getLineEndOffset(caret.getBufferPosition().line)
// To handle the case where visual mode allows the user to go past the end of the line
if (pos > 0 && pos >= lineEnd) {
pos = lineEnd - 1
}
val chars = editor.text()
val charPairs = parsMatchPairsOption(editor)
val pairChars = charPairs.keys + charPairs.values
if (!pairChars.contains(chars[pos])) {
pos = chars.indexOfAnyOrNull(pairChars.toCharArray(), pos, lineEnd, null) ?: return null
}
val charToMatch = chars[pos]
// TODO [vakhitov] should I implement BiMap for IdeaVim?
val pairChar = charPairs[charToMatch] ?: charPairs.entries.first { it.value == charToMatch }.key
val direction = if (charPairs.contains(charToMatch)) Direction.FORWARDS else Direction.BACKWARDS
return findMatchingChar(editor, pos, charToMatch, pairChar, direction)
}
private fun parsMatchPairsOption(editor: VimEditor): Map<Char, Char> {
return injector.options(editor).matchpairs
.filter { it.length == 3 }
.associate { it[0] to it[2] }
}
/**
* Our implementation differs from the Vim one, but it is more consistent and uses the power of IDE.
* We don't just count for opening and closing braces till their number will be equal, but keep context in mind.
* If the first brace is inside string or comment, then the second one should be also in the same string or comment; otherwise there is no match.
*/
public fun findMatchingChar(editor: VimEditor, start: Int, charToMatch: Char, pairChar: Char, direction: Direction): Int? {
// If we are inside string, we search for the pair inside the string only
val stringRange = getStringAtPos(editor, start, true)
if (stringRange != null && start in stringRange) {
return findBlockLocation(editor, stringRange, start, charToMatch, pairChar, direction)
}
val comment = injector.psiService.getCommentAtPos(editor, start)
if (comment != null && start in comment.first) {
val prefixToSuffix = comment.second
return if (prefixToSuffix != null) {
// If it is a block comment (has prefix & suffix), we search for the pair inside the block only
findBlockLocation(editor, comment.first, start, charToMatch, pairChar, direction)
} else {
// If it is not a block comment, that there may be a sequence of single line comments, and we want to iterate over
// all of them in an attempt to find a matching char
val commentRange = getRangeOfNonBlockComments(editor, comment.first, direction)
findBlockLocation(editor, commentRange, start, charToMatch, pairChar, direction)
}
}
return findBlockLocation(editor, start, charToMatch, pairChar, direction, 0)
}
private fun getRangeOfNonBlockComments(editor: VimEditor, startComment: TextRange, direction: Direction): TextRange {
var lastComment: TextRange = startComment
while (true) {
val nextNonWhitespaceChar = if (direction == Direction.FORWARDS) {
findNextNonWhitespaceChar(editor.text(), lastComment.endOffset)
} else {
findPreviousNonWhitespaceChar(editor.text(), lastComment.startOffset - 1)
} ?: break
val nextComment = injector.psiService.getCommentAtPos(editor, nextNonWhitespaceChar)
if (nextComment != null && nextComment.second == null) {
lastComment = nextComment.first
} else {
break
}
}
return if (direction == Direction.FORWARDS) {
TextRange(startComment.startOffset, lastComment.endOffset)
} else {
TextRange(lastComment.startOffset, startComment.endOffset)
}
}
private fun findNextNonWhitespaceChar(chars: CharSequence, startIndex: Int): Int? {
for (i in startIndex .. chars.lastIndex) {
if (!chars[i].isWhitespace()) {
return i
}
}
return null
}
private fun findPreviousNonWhitespaceChar(chars: CharSequence, startIndex: Int): Int? {
for (i in startIndex downTo 0) {
if (!chars[i].isWhitespace()) {
return i
}
}
return null
}
/**
* @see `[{`, `]}` and similar Vim commands.
* @return position of [count] unmatched [type]
*/
public fun findUnmatchedBlock(editor: VimEditor, pos: Int, type: Char, count: Int): Int? {
val chars: CharSequence = editor.text()
val loc = BLOCK_CHARS.indexOf(type)
val direction = if (loc % 2 == 0) Direction.BACKWARDS else Direction.FORWARDS
val charToMatch = BLOCK_CHARS[loc]
val pairChar = BLOCK_CHARS[loc - direction.toInt()]
val start = if (pos < chars.length && (chars[pos] == type || chars[pos] == pairChar)) pos + direction.toInt() else pos
return findBlockLocation(editor, start, charToMatch, pairChar, direction, count)
}
private fun findBlockLocation(editor: VimEditor, range: TextRange, start: Int, charToMatch: Char, pairChar: Char, direction: Direction): Int? {
val strictEscapeMatching = true // Vim's default behavior, see `help cpoptions`
val chars = editor.text()
var depth = 0
val escapedRestriction = if (strictEscapeMatching) isEscaped(chars, start) else null
var i: Int? = start
while (i != null && i in range) {
val c = chars[i]
when (c) {
charToMatch -> depth++
pairChar -> depth--
}
if (depth == 0) return i
// TODO what should we do inside strings?
i = chars.indexOfAnyOrNullInDirection(charArrayOf(charToMatch, pairChar), i + direction.toInt(), escapedRestriction, direction)
}
return null
}
private fun findBlockLocation(editor: VimEditor, start: Int, charToMatch: Char, pairChar: Char, direction: Direction, delta: Int): Int? {
val strictEscapeMatching = true // Vim's default behavior, see `help cpoptions`
val chars = editor.text()
var result: Int? = null
var depth = 0
val escapedRestriction = if (strictEscapeMatching) isEscaped(chars, start) else null
var i: Int? = start
while (i != null) {
val rangeToSkip = getStringAtPos(editor, i, false) ?: injector.psiService.getCommentAtPos(editor, i)?.first
if (rangeToSkip != null) {
val searchStart = if (direction == Direction.FORWARDS) rangeToSkip.endOffset else rangeToSkip.startOffset - 1
i = chars.indexOfAnyOrNullInDirection(charArrayOf(charToMatch, pairChar), searchStart, escapedRestriction, direction)
} else {
when (chars[i]) {
charToMatch -> {
depth++
if (delta > 0) result = i // For `]}` and similar commands we should return the result even if [delta] is unachievable
}
pairChar -> {
depth--
if (delta < 0) result = i // For `]}` and similar commands we should return the result even if [delta] is unachievable
}
}
if (depth == delta) {
result = i // For standard `%` motion, which should return [result] only if we succeeded to match
break
}
i = chars.indexOfAnyOrNullInDirection(charArrayOf(charToMatch, pairChar), i + direction.toInt(), escapedRestriction, direction)
}
}
return result
}
private fun CharSequence.indexOfAnyOrNullInDirection(chars: CharArray, startIndex: Int, escaped: Boolean?, direction: Direction): Int? {
return if (direction == Direction.FORWARDS) {
this.indexOfAnyOrNull(chars, startIndex, length, escaped)
} else {
this.lastIndexOfAnyOrNull(chars, startIndex, -1, escaped)
}
}
public fun getDoubleQuotesRangeNoPSI(chars: CharSequence, currentPos: Int, isInner: Boolean): TextRange? =
getQuoteRangeNoPSI(chars, currentPos, isInner, false)
public fun getSingleQuotesRangeNoPSI(chars: CharSequence, currentPos: Int, isInner: Boolean): TextRange? =
getQuoteRangeNoPSI(chars, currentPos, isInner, true)
private fun getQuoteRangeNoPSI(chars: CharSequence, currentPos: Int, isInner: Boolean, isSingleQuotes: Boolean): TextRange? {
require(currentPos in 0 .. chars.lastIndex) // We can't use StrictMode here because I would like to test it without an injector initialized
val start = chars.lastIndexOf('\n', currentPos) + 1
val changes = quotesChanges(chars, start).takeWhileInclusive { it.position <= currentPos }
val beforePos = changes.lastOrNull { it.position < currentPos }
val atPos = changes.firstOrNull { it.position == currentPos }
val afterPos = changes.firstOrNull { it.position > currentPos }
val inQuoteCheck: (State) -> Boolean = if (isSingleQuotes) State::isInSingleQuotes else State::isInDoubleQuotes
val openToClose: Pair<State, State> = if (beforePos != null && atPos != null && inQuoteCheck.invoke(beforePos)) {
beforePos to atPos
} else if (beforePos != null && afterPos != null && inQuoteCheck.invoke(beforePos)) {
beforePos to afterPos
} else if (atPos != null && afterPos != null && inQuoteCheck.invoke(atPos)) {
atPos to afterPos
} else {
return null
}
return if (isInner) {
TextRange(openToClose.first.position + 1, openToClose.second.position)
} else {
TextRange(openToClose.first.position, openToClose.second.position + 1)
}
}
private fun isEscaped(chars: CharSequence, pos: Int): Boolean {
var backslashCounter = 0
var i = pos
while (i-- > 0 && chars[i] == '\\') {
backslashCounter++
}
return backslashCounter % 2 != 0
}
private data class State(val position: Int, val isInSingleQuotes: Boolean, val isInDoubleQuotes: Boolean)
private fun quotesChanges(chars: CharSequence, startIndex: Int) = sequence {
var isInDoubleQuotes = false
var isInSingleQuotes = false
val eolIndex = chars.indexOfOrNull('\n', startIndex) ?: chars.length
var nextQuoteIndex = chars.indexOfAnyOrNull(charArrayOf('"', '\''), startIndex, eolIndex, escaped = false)
while (nextQuoteIndex != null) {
val quotes = chars[nextQuoteIndex]
when (quotes) {
'"' -> {
if (!isInSingleQuotes) {
isInDoubleQuotes = !isInDoubleQuotes
yield(State(nextQuoteIndex, false, isInDoubleQuotes))
}
}
'\'' -> {
if (!isInDoubleQuotes) {
isInSingleQuotes = !isInSingleQuotes
yield(State(nextQuoteIndex, isInSingleQuotes, false))
}
}
}
nextQuoteIndex = chars.indexOfAnyOrNull(charArrayOf('"', '\''), nextQuoteIndex + 1, eolIndex, escaped = false)
}
}
private fun <T> Sequence<T>.takeWhileInclusive(predicate: (T) -> Boolean) = sequence {
with(iterator()) {
while (hasNext()) {
val next = next()
yield(next)
if (!predicate(next)) break
}
}
}

View File

@ -0,0 +1,86 @@
/*
* Copyright 2003-2024 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.group
/**
* Searches for a single character and returns its index. Supports optional escaping.
*
* @param char The character to look for.
* @param startIndex The index to start the search from (inclusive).
* @param endIndex The index to end the search (exclusive).
* @param escaped If true, the method returns the index of char only if it's escaped. If false, it returns the index only if it's not escaped. If null, escaping is not considered.
* @return The index of the character, or null if it could not be located within the specified range.
*/
public fun CharSequence.indexOfOrNull(char: Char, startIndex: Int = 0, endIndex: Int = length, escaped: Boolean? = null): Int? {
return indexOfAnyOrNull(charArrayOf(char), startIndex, endIndex, escaped)
}
/**
* Searches for any character from the specified array and returns its index. Supports optional escaping.
*
* @param chars An array of characters to search for.
* @param startIndex The index to start the search from (inclusive).
* @param endIndex The index to end the search (exclusive).
* @param escaped If true, the method returns the index of char only if it's escaped. If false, it returns the index only if it's not escaped. If null, escaping is not considered.
* @return The first index of any character from the array, or null if none could be found within the specified range.
*/
public fun CharSequence.indexOfAnyOrNull(chars: CharArray, startIndex: Int = 0, endIndex: Int = length, escaped: Boolean? = null): Int? {
for (i in startIndex until kotlin.math.min(endIndex, length)) {
if (chars.contains(get(i)) &&
(
escaped == null ||
(escaped == true && isEscaped(this, i)) ||
(escaped == false && !isEscaped(this, i))
)
) {
return i
}
}
return null
}
/**
* Searches for any character from the specified array in reverse and returns its index. Supports optional escaping.
*
* @param chars An array of characters to search for.
* @param startIndex The index to start the search from (in reverse). The default is the end of the string.
* @param endIndex The index to finish the search, exclusive. If the index is -1, the last checked index will be 0. By default, it's the start of the string.
* @param escaped If true, the method returns the index of char only if it's escaped. If false, it returns the index only if it's not escaped. If null, escaping is not considered.
* @return The last index of any character from the array, or null if none could be found within the specified range.
*/
public fun CharSequence.lastIndexOfAnyOrNull(chars: CharArray, startIndex: Int = lastIndex, endIndex: Int = -1, escaped: Boolean? = null): Int? {
for (i in startIndex downTo kotlin.math.max(endIndex + 1, 0)) {
if (chars.contains(get(i)) &&
(
escaped == null ||
(escaped == true && isEscaped(this, i)) ||
(escaped == false && !isEscaped(this, i))
)
) {
return i
}
}
return null
}
/**
* Checks whether a character at the specified position is escaped (preceded by an odd number of backslashes).
*
* @param chars The character sequence.
* @param pos The position of the character to check.
* @return true if the character is escaped, and false otherwise.
*/
private fun isEscaped(chars: CharSequence, pos: Int): Boolean {
var backslashCounter = 0
var i = pos
while (i-- > 0 && chars[i] == '\\') {
backslashCounter++
}
return backslashCounter % 2 != 0
}

View File

@ -0,0 +1,138 @@
/*
* Copyright 2003-2024 The IdeaVim authors
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at
* https://opensource.org/licenses/MIT.
*/
package com.maddyhome.idea.vim.groups
import com.maddyhome.idea.vim.common.TextRange
import com.maddyhome.idea.vim.group.getDoubleQuotesRangeNoPSI
import com.maddyhome.idea.vim.group.getSingleQuotesRangeNoPSI
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class SearchGroupNoPSITests {
@Test
fun `test outer single quotes range for position outside string`() {
val text = "let myVar = 'Oh, hi Mark'"
assertNull(getSingleQuotesRangeNoPSI(text, 1, false))
}
@Test
fun `test inner single quotes range for position outside string`() {
val text = "let myVar = 'Oh, hi Mark'"
assertNull(getSingleQuotesRangeNoPSI(text, 1, true))
}
@Test
fun `test outer single quotes range for position inside string`() {
val text = "let myVar = 'Oh, hi Mark'"
assertEquals(TextRange(12, 25), getSingleQuotesRangeNoPSI(text, 15, false))
}
@Test
fun `test inner single quotes range for position inside string`() {
val text = "let myVar = 'Oh, hi Mark'"
assertEquals(TextRange(13, 24), getSingleQuotesRangeNoPSI(text, 15, true))
}
@Test
fun `test outer single quotes range for position at opening quote`() {
val text = "let myVar = 'Oh, hi Mark'"
assertEquals(TextRange(12, 25), getSingleQuotesRangeNoPSI(text, 12, false))
}
@Test
fun `test inner single quotes range for position at opening quote`() {
val text = "let myVar = 'Oh, hi Mark'"
assertEquals(TextRange(13, 24), getSingleQuotesRangeNoPSI(text, 12, true))
}
@Test
fun `test outer single quotes range for position at closing quote`() {
val text = "let myVar = 'Oh, hi Mark'"
assertEquals(TextRange(12, 25), getSingleQuotesRangeNoPSI(text, 24, false))
}
@Test
fun `test inner single quotes range for position at closing quote`() {
val text = "let myVar = 'Oh, hi Mark'"
assertEquals(TextRange(13, 24), getSingleQuotesRangeNoPSI(text, 24, true))
}
@Test
fun `test outer single quotes range for position after closing quote`() {
val text = "let myVar = 'Oh, hi Mark' // comment"
assertNull(getSingleQuotesRangeNoPSI(text, 25, false))
}
@Test
fun `test inner single quotes range for position after closing quote`() {
val text = "let myVar = 'Oh, hi Mark' // comment"
assertNull(getSingleQuotesRangeNoPSI(text, 25, true))
}
@Test
fun `test outer double quotes range for position outside string`() {
val text = "let myVar = \"Oh, hi Mark\""
assertNull(getDoubleQuotesRangeNoPSI(text, 1, false))
}
@Test
fun `test inner double quotes range for position outside string`() {
val text = "let myVar = \"Oh, hi Mark\""
assertNull(getDoubleQuotesRangeNoPSI(text, 1, true))
}
@Test
fun `test outer double quotes range for position inside string`() {
val text = "let myVar = \"Oh, hi Mark\""
assertEquals(TextRange(12, 25), getDoubleQuotesRangeNoPSI(text, 15, false))
}
@Test
fun `test inner double quotes range for position inside string`() {
val text = "let myVar = \"Oh, hi Mark\""
assertEquals(TextRange(13, 24), getDoubleQuotesRangeNoPSI(text, 15, true))
}
@Test
fun `test outer double quotes range for position at opening quote`() {
val text = "let myVar = \"Oh, hi Mark\""
assertEquals(TextRange(12, 25), getDoubleQuotesRangeNoPSI(text, 12, false))
}
@Test
fun `test inner double quotes range for position at opening quote`() {
val text = "let myVar = \"Oh, hi Mark\""
assertEquals(TextRange(13, 24), getDoubleQuotesRangeNoPSI(text, 12, true))
}
@Test
fun `test outer double quotes range for position at closing quote`() {
val text = "let myVar = \"Oh, hi Mark\""
assertEquals(TextRange(12, 25), getDoubleQuotesRangeNoPSI(text, 24, false))
}
@Test
fun `test inner double quotes range for position at closing quote`() {
val text = "let myVar = \"Oh, hi Mark\""
assertEquals(TextRange(13, 24), getDoubleQuotesRangeNoPSI(text, 24, true))
}
@Test
fun `test outer double quotes range for position after closing quote`() {
val text = "let myVar = \"Oh, hi Mark\" // comment"
assertNull(getDoubleQuotesRangeNoPSI(text, 25, false))
}
@Test
fun `test inner double quotes range for position after closing quote`() {
val text = "let myVar = \"Oh, hi Mark\" // comment"
assertNull(getDoubleQuotesRangeNoPSI(text, 25, true))
}
}