1
0
mirror of https://github.com/chylex/IntelliJ-IdeaVim.git synced 2025-05-06 21:34:02 +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
* 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.VimExtensionFacade.putExtensionHandlerMapping
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.helper.enumSetOf
import com.maddyhome.idea.vim.state.KeyHandlerState
import java.util.*
import java.util.EnumSet
/**
* 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>
* mini ai provides the next motions:
* <ul>
* <li>aq around any quotes.</li>
* <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>
* 1) Among all candidate pairs, pick any that cover the caret (start <= caret < end).
* Among those, pick the smallest range.
* 2) If none cover the caret, pick the "next" pair (start >= caret) that is closest.
* 3) If none are next, pick the "previous" pair (end <= caret) that is closest.
*
* @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 {
// Constants for key mappings
// <Plug> mappings
private const val PLUG_AQ = "<Plug>mini-ai-aq"
private const val PLUG_IQ = "<Plug>mini-ai-iq"
private const val PLUG_AB = "<Plug>mini-ai-ab"
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_IQ = "iq"
private const val KEY_AB = "ab"
@ -64,118 +59,46 @@ internal class MiniAI : VimExtension {
}
private fun registerMappings() {
val mappings = listOf(
PLUG_AQ to AroundAnyQuotesHandler(),
PLUG_IQ to InsideAnyQuotesHandler(),
PLUG_AB to AroundAnyBracketsHandler(),
PLUG_IB to InsideAnyBracketsHandler()
)
mappings.forEach { (key, handler) ->
putExtensionHandlerMapping(MappingMode.XO, injector.parser.parseKeys(key), owner, handler, false)
fun createHandler(
rangeFunc: (VimEditor, ImmutableVimCaret, Boolean) -> TextRange?
): ExtensionHandler = object : ExtensionHandler {
override val isRepeatable = true
override fun execute(editor: VimEditor, context: ExecutionContext, operatorArguments: OperatorArguments) {
addAction(PortedMiniAiAction(rangeFunc))
}
}
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_IQ to PLUG_IQ,
KEY_AB to PLUG_AB,
KEY_IB to PLUG_IB
)
keyMappings.forEach { (key, plug) ->
).forEach { (key, plug) ->
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 visualType: TextObjectVisualType = TextObjectVisualType.CHARACTER_WISE
override fun getRange(
@ -183,71 +106,185 @@ class MotionOuterAnyBracketProximityAction : TextObjectActionHandler() {
caret: ImmutableVimCaret,
context: ExecutionContext,
count: Int,
rawCount: Int,
): TextRange? {
return findClosestBracketRange(editor, caret, count, true)
rawCount: Int
): TextRange? = rangeFunc(editor, caret, true).also {
// 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) {
val keyHandlerState: KeyHandlerState = KeyHandler.getInstance().keyHandlerState
keyHandlerState.commandBuilder.addAction(action)
KeyHandler.getInstance().keyHandlerState.commandBuilder.addAction(action)
}
private fun findClosestDelimitedRange(
caret: ImmutableVimCaret,
delimiters: List<Char>,
findRange: (Char) -> TextRange?
): TextRange? {
val allRanges = delimiters.mapNotNull { char ->
findRange(char)?.let { range ->
DelimitedRange(range, char)
/* -------------------------------------------------------------------------
* Core mini.ai-like logic
* ------------------------------------------------------------------------- */
/**
* Find a text range for quotes (", ', `) around the caret using a "cover-or-next" approach.
* - If one or more pairs **cover** the caret, pick the *smallest* covering pair.
* - 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
val containingRanges = allRanges.filter {
caret.offset in it.range.startOffset..it.range.endOffset
// 1) Covering, smallest width
if (covering.isNotEmpty()) {
return covering.minByOrNull { it.endOffset - it.startOffset }
}
// If we have containing ranges, return the smallest one
if (containingRanges.isNotEmpty()) {
return containingRanges.minBy {
it.range.endOffset - it.range.startOffset
}.range
// 2) Next (closest by startOffset)
if (nextOnes.isNotEmpty()) {
return nextOnes.minByOrNull { kotlin.math.abs(it.startOffset - caretOffset) }
}
// If no containing ranges, find the closest one
return allRanges
.minByOrNull { range ->
kotlin.math.abs(caret.offset - range.range.startOffset)
}?.range
// 3) Previous (closest by endOffset)
if (prevOnes.isNotEmpty()) {
return prevOnes.minByOrNull { kotlin.math.abs(it.endOffset - caretOffset) }
}
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
@Test
fun testChangeInsideBackQuoteWithNestedSingleQuote() {