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

feat: improve any brackets behavior

This commit is contained in:
Osvaldo Cordova Aburto 2025-02-02 13:46:12 -06:00 committed by Alex Pláte
parent ffee3ccbeb
commit 491a96825f
2 changed files with 358 additions and 170 deletions
src
main/java/com/maddyhome/idea/vim/extension/miniai
test/java/org/jetbrains/plugins/ideavim/extension/miniai

View File

@ -1,5 +1,5 @@
/* /*
* Copyright 2003-2023 The IdeaVim authors * Copyright 2003-2025 The IdeaVim authors
* *
* Use of this source code is governed by an MIT-style * Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE.txt file or at * license that can be found in the LICENSE.txt file or at
@ -21,36 +21,31 @@ import com.maddyhome.idea.vim.extension.ExtensionHandler
import com.maddyhome.idea.vim.extension.VimExtension import com.maddyhome.idea.vim.extension.VimExtension
import com.maddyhome.idea.vim.extension.VimExtensionFacade.putExtensionHandlerMapping import com.maddyhome.idea.vim.extension.VimExtensionFacade.putExtensionHandlerMapping
import com.maddyhome.idea.vim.extension.VimExtensionFacade.putKeyMapping import com.maddyhome.idea.vim.extension.VimExtensionFacade.putKeyMapping
import com.maddyhome.idea.vim.group.findBlockRange
import com.maddyhome.idea.vim.handler.TextObjectActionHandler import com.maddyhome.idea.vim.handler.TextObjectActionHandler
import com.maddyhome.idea.vim.helper.enumSetOf import com.maddyhome.idea.vim.helper.enumSetOf
import com.maddyhome.idea.vim.state.KeyHandlerState import java.util.EnumSet
import java.util.*
/** /**
* Extend and create a/i textobjects * A simplified imitation of mini.ai approach for motions "aq", "iq", "ab", "ib".
* Instead of "closest" text object, we apply a "cover-or-next" logic:
* *
* <p> * 1) Among all candidate pairs, pick any that cover the caret (start <= caret < end).
* mini ai provides the next motions: * Among those, pick the smallest range.
* <ul> * 2) If none cover the caret, pick the "next" pair (start >= caret) that is closest.
* <li>aq around any quotes.</li> * 3) If none are next, pick the "previous" pair (end <= caret) that is closest.
* <li>iq inside any quotes.</li>
* <li>ab around any parentheses, curly braces and square brackets.</li>
* <li>ib inside any parentheses, curly braces and square brackets.</li>
* </ul>
* *
* @author Osvaldo Cordova Aburto (@oca159) * For "i" text object, we shrink the boundaries inward by one character on each side.
*/ */
internal class MiniAI : VimExtension { class MiniAI : VimExtension {
companion object { companion object {
// Constants for key mappings // <Plug> mappings
private const val PLUG_AQ = "<Plug>mini-ai-aq" private const val PLUG_AQ = "<Plug>mini-ai-aq"
private const val PLUG_IQ = "<Plug>mini-ai-iq" private const val PLUG_IQ = "<Plug>mini-ai-iq"
private const val PLUG_AB = "<Plug>mini-ai-ab" private const val PLUG_AB = "<Plug>mini-ai-ab"
private const val PLUG_IB = "<Plug>mini-ai-ib" private const val PLUG_IB = "<Plug>mini-ai-ib"
// Constants for key sequences // Actual user key sequences
private const val KEY_AQ = "aq" private const val KEY_AQ = "aq"
private const val KEY_IQ = "iq" private const val KEY_IQ = "iq"
private const val KEY_AB = "ab" private const val KEY_AB = "ab"
@ -64,118 +59,46 @@ internal class MiniAI : VimExtension {
} }
private fun registerMappings() { private fun registerMappings() {
val mappings = listOf( fun createHandler(
PLUG_AQ to AroundAnyQuotesHandler(), rangeFunc: (VimEditor, ImmutableVimCaret, Boolean) -> TextRange?
PLUG_IQ to InsideAnyQuotesHandler(), ): ExtensionHandler = object : ExtensionHandler {
PLUG_AB to AroundAnyBracketsHandler(), override val isRepeatable = true
PLUG_IB to InsideAnyBracketsHandler() override fun execute(editor: VimEditor, context: ExecutionContext, operatorArguments: OperatorArguments) {
) addAction(PortedMiniAiAction(rangeFunc))
}
mappings.forEach { (key, handler) ->
putExtensionHandlerMapping(MappingMode.XO, injector.parser.parseKeys(key), owner, handler, false)
} }
val keyMappings = listOf( listOf(
// Outer quotes
PLUG_AQ to createHandler { e, c, _ -> findQuoteRange(e, c, isOuter = true) },
// Inner quotes
PLUG_IQ to createHandler { e, c, _ -> findQuoteRange(e, c, isOuter = false) },
// Outer brackets
PLUG_AB to createHandler { e, c, _ -> findBracketRange(e, c, isOuter = true) },
// Inner brackets
PLUG_IB to createHandler { e, c, _ -> findBracketRange(e, c, isOuter = false) }
).forEach { (plug, handler) ->
putExtensionHandlerMapping(MappingMode.XO, injector.parser.parseKeys(plug), owner, handler, false)
}
// Map user keys -> <Plug> keys
listOf(
KEY_AQ to PLUG_AQ, KEY_AQ to PLUG_AQ,
KEY_IQ to PLUG_IQ, KEY_IQ to PLUG_IQ,
KEY_AB to PLUG_AB, KEY_AB to PLUG_AB,
KEY_IB to PLUG_IB KEY_IB to PLUG_IB
) ).forEach { (key, plug) ->
keyMappings.forEach { (key, plug) ->
putKeyMapping(MappingMode.XO, injector.parser.parseKeys(key), owner, injector.parser.parseKeys(plug), true) putKeyMapping(MappingMode.XO, injector.parser.parseKeys(key), owner, injector.parser.parseKeys(plug), true)
} }
} }
private class InsideAnyQuotesHandler : ExtensionHandler {
override val isRepeatable = true
override fun execute(editor: VimEditor, context: ExecutionContext, operatorArguments: OperatorArguments) {
addAction(MotionInnerAnyQuoteProximityAction())
}
}
private class AroundAnyQuotesHandler : ExtensionHandler {
override val isRepeatable = true
override fun execute(editor: VimEditor, context: ExecutionContext, operatorArguments: OperatorArguments) {
addAction(MotionOuterAnyQuoteProximityAction())
}
}
private class InsideAnyBracketsHandler : ExtensionHandler {
override val isRepeatable = true
override fun execute(editor: VimEditor, context: ExecutionContext, operatorArguments: OperatorArguments) {
addAction(MotionInnerAnyBracketProximityAction())
}
}
private class AroundAnyBracketsHandler : ExtensionHandler {
override val isRepeatable = true
override fun execute(editor: VimEditor, context: ExecutionContext, operatorArguments: OperatorArguments) {
addAction(MotionOuterAnyBracketProximityAction())
}
}
class MotionInnerAnyQuoteProximityAction : TextObjectActionHandler() {
override val flags: EnumSet<CommandFlags> = enumSetOf(CommandFlags.FLAG_TEXT_BLOCK)
override val visualType: TextObjectVisualType = TextObjectVisualType.CHARACTER_WISE
override fun getRange(
editor: VimEditor,
caret: ImmutableVimCaret,
context: ExecutionContext,
count: Int,
rawCount: Int,
): TextRange? {
return findClosestQuoteRange(editor, caret, false)
}
}
class MotionOuterAnyQuoteProximityAction : TextObjectActionHandler() {
override val flags: EnumSet<CommandFlags> = enumSetOf(CommandFlags.FLAG_TEXT_BLOCK)
override val visualType: TextObjectVisualType = TextObjectVisualType.CHARACTER_WISE
override fun getRange(
editor: VimEditor,
caret: ImmutableVimCaret,
context: ExecutionContext,
count: Int,
rawCount: Int,
): TextRange? {
return findClosestQuoteRange(editor, caret, true)
}
}
class MotionInnerAnyBracketProximityAction : TextObjectActionHandler() {
override val flags: EnumSet<CommandFlags> = enumSetOf(CommandFlags.FLAG_TEXT_BLOCK)
override val visualType: TextObjectVisualType = TextObjectVisualType.CHARACTER_WISE
override fun getRange(
editor: VimEditor,
caret: ImmutableVimCaret,
context: ExecutionContext,
count: Int,
rawCount: Int,
): TextRange? {
return findClosestBracketRange(editor, caret, count, false)
}
}
} }
class MotionOuterAnyBracketProximityAction : TextObjectActionHandler() { // A text object action that uses the "mini.ai-like" picking strategy.
private class PortedMiniAiAction(
private val rangeFunc: (VimEditor, ImmutableVimCaret, Boolean) -> TextRange?
) : TextObjectActionHandler() {
override val flags: EnumSet<CommandFlags> = enumSetOf(CommandFlags.FLAG_TEXT_BLOCK) override val flags: EnumSet<CommandFlags> = enumSetOf(CommandFlags.FLAG_TEXT_BLOCK)
override val visualType: TextObjectVisualType = TextObjectVisualType.CHARACTER_WISE override val visualType: TextObjectVisualType = TextObjectVisualType.CHARACTER_WISE
override fun getRange( override fun getRange(
@ -183,71 +106,185 @@ class MotionOuterAnyBracketProximityAction : TextObjectActionHandler() {
caret: ImmutableVimCaret, caret: ImmutableVimCaret,
context: ExecutionContext, context: ExecutionContext,
count: Int, count: Int,
rawCount: Int, rawCount: Int
): TextRange? { ): TextRange? = rangeFunc(editor, caret, true).also {
return findClosestBracketRange(editor, caret, count, true) // We don't do 'count' expansions here. If you wanted to replicate
// mini.ai's multi-level expansions, you'd call the "rangeFunc" multiple
// times re-feeding the last output as reference, etc.
} }
} }
// Utility to register action in KeyHandler
private fun addAction(action: TextObjectActionHandler) { private fun addAction(action: TextObjectActionHandler) {
val keyHandlerState: KeyHandlerState = KeyHandler.getInstance().keyHandlerState KeyHandler.getInstance().keyHandlerState.commandBuilder.addAction(action)
keyHandlerState.commandBuilder.addAction(action)
} }
private fun findClosestDelimitedRange( /* -------------------------------------------------------------------------
caret: ImmutableVimCaret, * Core mini.ai-like logic
delimiters: List<Char>, * ------------------------------------------------------------------------- */
findRange: (Char) -> TextRange?
): TextRange? { /**
val allRanges = delimiters.mapNotNull { char -> * Find a text range for quotes (", ', `) around the caret using a "cover-or-next" approach.
findRange(char)?.let { range -> * - If one or more pairs **cover** the caret, pick the *smallest* covering pair.
DelimitedRange(range, char) * - Else pick the "next" pair whose start >= caret offset (closest).
* - Else pick the "previous" pair whose end <= caret offset (closest).
*
* If [isOuter] == false (i.e. 'iq'), shrink the final range by 1 char on each side.
*/
private fun findQuoteRange(editor: VimEditor, caret: ImmutableVimCaret, isOuter: Boolean): TextRange? {
val text = editor.text()
val caretOffset = caret.offset
val caretLine = caret.getLine()
// 1) Gather quotes in *this caret's line*
val lineStart = editor.getLineStartOffset(caretLine)
val lineEnd = editor.getLineEndOffset(caretLine)
val lineText = text.substring(lineStart, lineEnd)
val lineRanges = gatherAllQuoteRanges(lineText).map {
TextRange(it.startOffset + lineStart, it.endOffset + lineStart)
}
val localBest = pickBestRange(lineRanges, caretOffset)
if (localBest != null) {
return adjustRangeForInnerOuter(localBest, isOuter)
}
// 2) Fallback: entire buffer
val allRanges = gatherAllQuoteRanges(text)
val bestOverall = pickBestRange(allRanges, caretOffset) ?: return null
return adjustRangeForInnerOuter(bestOverall, isOuter)
}
/** Adjust final range if user requested 'inner' (i.e. skip bounding chars). */
private fun adjustRangeForInnerOuter(range: TextRange, isOuter: Boolean): TextRange? {
if (isOuter) return range
// For 'inner', skip bounding chars if possible
if (range.endOffset - range.startOffset < 2) return null
return TextRange(range.startOffset + 1, range.endOffset - 1)
}
/**
* Gather all "balanced" pairs for single/double/backtick quotes in the entire text.
* For simplicity, we treat each ["]...["] or [']...['] or [`]...[`] as one range,
* ignoring complicated cases of escaping, multi-line, etc.
*/
private fun gatherAllQuoteRanges(text: CharSequence): List<TextRange> {
val results = mutableListOf<TextRange>()
val patterns = listOf(
"\"([^\"]*)\"",
"'([^']*)'",
"`([^`]*)`"
)
for (p in patterns) {
Regex(p).findAll(text).forEach {
results.add(TextRange(it.range.first, it.range.last + 1))
}
}
return results
}
/**
* Find a text range for brackets using a "cover-or-next" approach.
* We treat bracket pairs ( (), [], {}, <> ) in a naive balanced scanning way.
* If [isOuter] is false, we shrink boundaries to skip the bracket chars.
*/
private fun findBracketRange(editor: VimEditor, caret: ImmutableVimCaret, isOuter: Boolean): TextRange? {
val text = editor.text()
val caretOffset = caret.offset
val caretLine = caret.getLine()
// 1) Gather bracket pairs in *this caret's line*
val lineStart = editor.getLineStartOffset(caretLine)
val lineEnd = editor.getLineEndOffset(caretLine)
val bracketChars = listOf('(', ')', '[', ']', '{', '}', '<', '>')
// Gather local line bracket pairs
val lineText = text.substring(lineStart, lineEnd)
val lineRanges = gatherAllBracketRanges(lineText, bracketChars).map {
// Shift each range's offsets to the global text
TextRange(it.startOffset + lineStart, it.endOffset + lineStart)
}
// Pick the best match on this line
val localBest = pickBestRange(lineRanges, caretOffset)
if (localBest != null) {
return adjustRangeForInnerOuter(localBest, isOuter)
}
// 2) Fallback: gather bracket pairs in the entire file
val allRanges = gatherAllBracketRanges(text, bracketChars)
val bestOverall = pickBestRange(allRanges, caretOffset) ?: return null
return adjustRangeForInnerOuter(bestOverall, isOuter)
}
/**
* Gathers naive "balanced bracket" ranges for the given bracket pairs.
* This is a simplified stack-based approach scanning the entire file text.
*/
private fun gatherAllBracketRanges(
text: CharSequence,
brackets: List<Char>
): List<TextRange> {
val pairs = mapOf('(' to ')', '[' to ']', '{' to '}', '<' to '>')
val results = mutableListOf<TextRange>()
val stack = ArrayDeque<Int>() // offsets of open bracket
val bracketTypeStack = ArrayDeque<Char>() // store which bracket
text.forEachIndexed { i, ch ->
if (pairs.containsKey(ch)) {
// Opening bracket
stack.addLast(i)
bracketTypeStack.addLast(ch)
} else {
// Maybe a closing bracket?
val top = bracketTypeStack.lastOrNull() ?: '\u0000'
if (pairs[top] == ch) {
// Balanced pair
val openPos = stack.removeLast()
bracketTypeStack.removeLast()
results.add(TextRange(openPos, i + 1)) // i+1 for endOffset
}
}
}
return results
}
/**
* Picks best range among [candidates] in a cover-or-next approach:
* 1) Among those covering [caretOffset], pick the narrowest.
* 2) Else pick the "next" bracket whose start >= caret, if any (closest).
* 3) Else pick the "previous" bracket whose end <= caret, if any (closest).
*/
private fun pickBestRange(candidates: List<TextRange>, caretOffset: Int): TextRange? {
if (candidates.isEmpty()) return null
val covering = mutableListOf<TextRange>()
val nextOnes = mutableListOf<TextRange>()
val prevOnes = mutableListOf<TextRange>()
for (r in candidates) {
if (r.startOffset <= caretOffset && caretOffset < r.endOffset) {
covering.add(r)
} else if (r.startOffset >= caretOffset) {
nextOnes.add(r)
} else if (r.endOffset <= caretOffset) {
prevOnes.add(r)
} }
} }
// First, find all ranges that contain the caret // 1) Covering, smallest width
val containingRanges = allRanges.filter { if (covering.isNotEmpty()) {
caret.offset in it.range.startOffset..it.range.endOffset return covering.minByOrNull { it.endOffset - it.startOffset }
} }
// If we have containing ranges, return the smallest one // 2) Next (closest by startOffset)
if (containingRanges.isNotEmpty()) { if (nextOnes.isNotEmpty()) {
return containingRanges.minBy { return nextOnes.minByOrNull { kotlin.math.abs(it.startOffset - caretOffset) }
it.range.endOffset - it.range.startOffset
}.range
} }
// If no containing ranges, find the closest one // 3) Previous (closest by endOffset)
return allRanges if (prevOnes.isNotEmpty()) {
.minByOrNull { range -> return prevOnes.minByOrNull { kotlin.math.abs(it.endOffset - caretOffset) }
kotlin.math.abs(caret.offset - range.range.startOffset) }
}?.range
return null
} }
private fun findClosestBracketRange(
editor: VimEditor,
caret: ImmutableVimCaret,
count: Int,
isOuter: Boolean
): TextRange? {
val brackets = listOf('(', '[', '{')
return findClosestDelimitedRange(caret, brackets) { char ->
findBlockRange(editor, caret, char, count, isOuter)
}
}
private fun findClosestQuoteRange(
editor: VimEditor,
caret: ImmutableVimCaret,
isOuter: Boolean
): TextRange? {
val quotes = listOf('`', '"', '\'')
return findClosestDelimitedRange(caret, quotes) { char ->
injector.searchHelper.findBlockQuoteInLineRange(editor, caret, char, isOuter)
}
}
private data class DelimitedRange(
val range: TextRange,
val char: Char
)

View File

@ -37,6 +37,157 @@ class MiniAIExtensionTest : VimTestCase() {
} }
@Test
fun testFalseSingleQuoteInTheMiddle() {
doTest(
"ciq",
"'balanced'false <caret>string'balanced'",
"'balanced'false string'<caret>'",
Mode.INSERT,
JavaFileType.INSTANCE,
)
assertSelection(null)
}
@Test
fun testFalseDoubleQuoteInTheMiddle() {
doTest(
"ciq",
"\"balanced\"false <caret>string\"balanced\"",
"\"balanced\"false string\"<caret>\"",
Mode.INSERT,
JavaFileType.INSTANCE,
)
assertSelection(null)
}
@Test
fun testFalseBackQuoteInTheMiddle() {
doTest(
"ciq",
"`balanced`false <caret>string`balanced`",
"`balanced`false string`<caret>`",
Mode.INSERT,
JavaFileType.INSTANCE,
)
assertSelection(null)
}
@Test
fun testInsideMultilineSingleQuote() {
doTest(
"ciq",
"""'
something
<caret>
something
'""",
"''",
Mode.INSERT,
JavaFileType.INSTANCE,
)
assertSelection(null)
}
@Test
fun testInsideMultilineDoubleQuote() {
doTest(
"ciq",
""""
something
<caret>
something
"""",
"\"\"",
Mode.INSERT,
JavaFileType.INSTANCE,
)
assertSelection(null)
}
@Test
fun testInsideMultilineBackQuote() {
doTest(
"ciq",
"""`
something
<caret>
something
`""",
"``",
Mode.INSERT,
JavaFileType.INSTANCE,
)
assertSelection(null)
}
@Test
fun testNextInsideMultilineSingleQuote() {
doTest(
"ciq",
"""
<caret>
'
something
'
""",
"""
'<caret>'
""",
Mode.INSERT,
JavaFileType.INSTANCE,
)
assertSelection(null)
}
@Test
fun testNextInsideMultilineCurlyBracket() {
doTest(
"cib",
"""
{
<caret>
print(something)
}
""",
"""
{}
""",
Mode.INSERT,
JavaFileType.INSTANCE,
)
assertSelection(null)
}
@Test
fun testNextBracketInCurrentLineHasPriority() {
// Test case 1: Next bracket in same line has priority
doTest(
"cib",
"""
{
<caret> print(something) {nested}
}
""".trimIndent(),
"""
{
print(<caret>) {nested}
}
""".trimIndent(),
Mode.INSERT,
JavaFileType.INSTANCE,
)
assertSelection(null)
}
// To make sure the order in list is not relevant // To make sure the order in list is not relevant
@Test @Test
fun testChangeInsideBackQuoteWithNestedSingleQuote() { fun testChangeInsideBackQuoteWithNestedSingleQuote() {