From 13d858e0fb9739c2b1ea919522dad19187b45990 Mon Sep 17 00:00:00 2001 From: davidot <3750649+davidot@users.noreply.github.com> Date: Mon, 12 Jan 2026 02:00:50 +0100 Subject: [PATCH 1/3] Fix: Make find all ignore case even when smartcase is active This manifests in the highlighting of search results, when * on a word with capitals smartcase was (correctly) ignored for the actual search but the highlights did use smartcase. --- .../ideavim/helper/SearchHelperTest.kt | 27 +++++++++++++++++++ .../idea/vim/api/VimSearchHelperBase.kt | 5 ++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/test/java/org/jetbrains/plugins/ideavim/helper/SearchHelperTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/helper/SearchHelperTest.kt index 4b47c21161..894a1fcd70 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/helper/SearchHelperTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/helper/SearchHelperTest.kt @@ -90,6 +90,33 @@ class SearchHelperTest : VimTestCase() { kotlin.test.assertEquals(previousWordPosition, text.indexOf("second")) } + + @TestWithoutNeovim(reason = SkipNeovimReason.NOT_VIM_TESTING) + @Test + fun testFindAllIgnoreCaseOverwritesSmartCase() { + val text = "Lorem ipsum lorem ipsum" + configureByText(text) + + val capitalMatch = TextRange(0, 5) + val lowerMatch = TextRange(12, 17) + + val search = { ignoreCase: Boolean -> + injector.searchHelper.findAll(fixture.editor.vim, "\\", 0, -1, ignoreCase) + } + + // Ensure ignore and smart case are off + enterCommand("set noignorecase") + enterCommand("set nosmartcase") + + kotlin.test.assertEquals(listOf(capitalMatch), search(false)) + // Even if both are off, ignore case should still ignore cases + kotlin.test.assertEquals(listOf(capitalMatch, lowerMatch), search(true)) + + enterCommand("set smartcase") + kotlin.test.assertEquals(listOf(capitalMatch), search(false)) + kotlin.test.assertEquals(listOf(capitalMatch, lowerMatch), search(true)) + } + @Test fun testMotionOuterWordAction() { doTest( diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimSearchHelperBase.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimSearchHelperBase.kt index 12b1a26953..ee9947d5ff 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimSearchHelperBase.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimSearchHelperBase.kt @@ -385,8 +385,9 @@ abstract class VimSearchHelperBase : VimSearchHelper { ignoreCase: Boolean, ): List { val options = enumSetOf() - if (injector.globalOptions().smartcase) options.add(VimRegexOptions.SMART_CASE) - if (injector.globalOptions().ignorecase) options.add(VimRegexOptions.IGNORE_CASE) + // If we are explicitly asked to ignore case, assume that smartcase check has already taken place + if (injector.globalOptions().smartcase && !ignoreCase) options.add(VimRegexOptions.SMART_CASE) + if (injector.globalOptions().ignorecase || ignoreCase) options.add(VimRegexOptions.IGNORE_CASE) val regex = try { VimRegex(pattern) } catch (e: VimRegexException) { From a3482639f9986414dfa8c97d993cf810821089be Mon Sep 17 00:00:00 2001 From: davidot <3750649+davidot@users.noreply.github.com> Date: Mon, 12 Jan 2026 02:00:50 +0100 Subject: [PATCH 2/3] Adds setting `maxhlduringincsearch` to limit highlights with incsearch This can otherwise lead to great slowdown particularly during initial typed letters. Because with incsearch highlighting is triggered and block, this means starting your search will highlight up to thousands of the same character only refined later. With this new setting this skips highlighting (during incsearch only) when above threshold, or keeps old behavior if set to -1. --- .../idea/vim/helper/SearchHighlightsHelper.kt | 27 +++++++++++++++---- src/main/resources/dictionaries/ideavim.dic | 1 + .../idea/vim/api/OptionProperties.kt | 1 + .../com/maddyhome/idea/vim/api/Options.kt | 1 + 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/maddyhome/idea/vim/helper/SearchHighlightsHelper.kt b/src/main/java/com/maddyhome/idea/vim/helper/SearchHighlightsHelper.kt index e27be4d7c8..5e941a7382 100644 --- a/src/main/java/com/maddyhome/idea/vim/helper/SearchHighlightsHelper.kt +++ b/src/main/java/com/maddyhome/idea/vim/helper/SearchHighlightsHelper.kt @@ -107,6 +107,12 @@ private fun updateSearchHighlights( && (currentEditor == null || it.projectId == currentEditor.projectId) } + val shouldIgnoreCase = pattern == null || shouldIgnoreCase(pattern, shouldIgnoreSmartCase) + + var maxhlduringincsearch = injector.globalOptions().maxhlduringincsearch + if (maxhlduringincsearch < 0) + maxhlduringincsearch = Int.MAX_VALUE + editors.forEach { val editor = it.ij var currentMatchOffset = -1 @@ -136,13 +142,24 @@ private fun updateSearchHighlights( pattern, searchStartLine, searchEndLine, - shouldIgnoreCase(pattern, shouldIgnoreSmartCase) + shouldIgnoreCase ) if (results.isNotEmpty()) { - if (editor === currentEditor?.ij) { - currentMatchOffset = findClosestMatch(results, initialOffset, count1, forwards) + // Only in incsearch is current editor not null, then check result size + val showHighlightsInEditor = currentEditor == null || results.size < maxhlduringincsearch + if (editor == currentEditor?.ij) { + val currentMatchIndex = findClosestMatch(results, initialOffset, count1, forwards) + currentMatchOffset = if (currentMatchIndex == -1) -1 else results[currentMatchIndex].startOffset + + if (!showHighlightsInEditor) { + // Always highlight at least the "current" match in the active editor + highlightSearchResults(editor, pattern, listOf(results[currentMatchIndex]), currentMatchOffset) + } + } + + if (showHighlightsInEditor) { + highlightSearchResults(editor, pattern, results, currentMatchOffset) } - highlightSearchResults(editor, pattern, results, currentMatchOffset) } } editor.vimLastSearch = pattern @@ -237,7 +254,7 @@ private fun findClosestMatch( return -1 } - return sortedResults[nextIndex % results.size].startOffset + return nextIndex % results.size } internal fun highlightSearchResults( diff --git a/src/main/resources/dictionaries/ideavim.dic b/src/main/resources/dictionaries/ideavim.dic index 7f47ddc6d1..3c72a62285 100644 --- a/src/main/resources/dictionaries/ideavim.dic +++ b/src/main/resources/dictionaries/ideavim.dic @@ -23,6 +23,7 @@ keymodel lookupKeys mapleader matchpairs +maxhlduringincsearch maxmapdepth nrformats operatorfunc diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/OptionProperties.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/OptionProperties.kt index 5116b778e7..eae57c3162 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/OptionProperties.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/OptionProperties.kt @@ -50,6 +50,7 @@ open class GlobalOptions(scope: OptionAccessScope) : OptionsPropertiesBase(scope var wrapscan: Boolean by optionProperty(Options.wrapscan) // IdeaVim specific options. Put any editor or IDE specific options in IjOptionProperties + var maxhlduringincsearch: Int by optionProperty(Options.maxhlduringincsearch) // Temporary flags for work-in-progress behaviour. Hidden from the output of `:set all` var ideastrictmode: Boolean by optionProperty(Options.ideastrictmode) diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/Options.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/Options.kt index f2ef52cd77..bd01cefe75 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/Options.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/Options.kt @@ -136,6 +136,7 @@ object Options { ) ) ) + val maxhlduringincsearch: NumberOption = addOption(NumberOption(name="maxhlduringincsearch", GLOBAL, "maxhld", 100, -1)) val maxmapdepth: NumberOption = addOption(NumberOption("maxmapdepth", GLOBAL, "mmd", 20)) val more: ToggleOption = addOption(ToggleOption("more", GLOBAL, "more", true)) val nrformats: StringListOption = addOption( From 52116dadf736fb794b287c5c53f6af598a2a5032 Mon Sep 17 00:00:00 2001 From: davidot <3750649+davidot@users.noreply.github.com> Date: Mon, 12 Jan 2026 02:00:50 +0100 Subject: [PATCH 3/3] Add option to display amount of occurrences of matches in file Controlled by new option `showmatchcount`. If enabled shows current/total matches in the main file being edited. For example [2/10] means 10 totals matches and your past match 1 and in or before match 2 in the file. The count is follows the active focussed editor around. --- .../idea/vim/helper/SearchHighlightsHelper.kt | 142 ++++++++++++++---- .../idea/vim/newapi/IjVimSearchGroup.kt | 4 +- .../maddyhome/idea/vim/ui/ex/ExEntryPanel.kt | 132 ++++++++++++---- src/main/resources/dictionaries/ideavim.dic | 1 + .../idea/vim/api/OptionProperties.kt | 1 + .../com/maddyhome/idea/vim/api/Options.kt | 1 + .../idea/vim/api/VimSearchGroupBase.kt | 16 +- 7 files changed, 225 insertions(+), 72 deletions(-) diff --git a/src/main/java/com/maddyhome/idea/vim/helper/SearchHighlightsHelper.kt b/src/main/java/com/maddyhome/idea/vim/helper/SearchHighlightsHelper.kt index 5e941a7382..3597f681be 100644 --- a/src/main/java/com/maddyhome/idea/vim/helper/SearchHighlightsHelper.kt +++ b/src/main/java/com/maddyhome/idea/vim/helper/SearchHighlightsHelper.kt @@ -27,6 +27,7 @@ import com.maddyhome.idea.vim.newapi.ij import com.maddyhome.idea.vim.newapi.vim import com.maddyhome.idea.vim.state.mode.inCommandLineModeWithVisual import com.maddyhome.idea.vim.state.mode.inVisualMode +import com.maddyhome.idea.vim.ui.ex.ExEntryPanel import org.jetbrains.annotations.Contract import java.awt.Font import java.util.* @@ -36,8 +37,9 @@ internal fun updateSearchHighlights( shouldIgnoreSmartCase: Boolean, showHighlights: Boolean, forceUpdate: Boolean, + newCaretPosition: Int? = null, ) { - updateSearchHighlights(null, pattern, 1, shouldIgnoreSmartCase, showHighlights, -1, null, true, forceUpdate) + updateSearchHighlights(null, pattern, 1, shouldIgnoreSmartCase, showHighlights, -1, null, true, forceUpdate, newCaretPosition) } internal fun updateIncsearchHighlights( @@ -63,7 +65,8 @@ internal fun updateIncsearchHighlights( searchStartOffset, searchRange, forwards, - false + false, + null ) } @@ -84,6 +87,12 @@ internal fun addSubstitutionConfirmationHighlight(editor: Editor, start: Int, en ) } +enum class CountMatchesState { + NoCountMatches, + ShowCount, + MaybeClearCount +} + /** * Refreshes current search highlights for all visible editors */ @@ -97,6 +106,7 @@ private fun updateSearchHighlights( searchRange: LineRange?, forwards: Boolean, forceUpdate: Boolean, + newCaretPosition: Int? ): Int { var currentEditorCurrentMatchOffset = -1 @@ -107,6 +117,18 @@ private fun updateSearchHighlights( && (currentEditor == null || it.projectId == currentEditor.projectId) } + val countMatchesSetting = injector.globalOptions().showmatchcount + var editorWithSearchMatches = if (countMatchesSetting) { currentEditor?.ij } else { null } + if (countMatchesSetting && editorWithSearchMatches == null) { + editorWithSearchMatches = injector.editorGroup.getFocusedEditor()?.ij + } + + if (!countMatchesSetting) { + // In case we were counting matches before clear it, the function + // itself ensures nothing happens if it is cleared already + ExEntryPanel.getOrCreatePanelInstance().clearStatusText() + } + val shouldIgnoreCase = pattern == null || shouldIgnoreCase(pattern, shouldIgnoreSmartCase) var maxhlduringincsearch = injector.globalOptions().maxhlduringincsearch @@ -115,13 +137,17 @@ private fun updateSearchHighlights( editors.forEach { val editor = it.ij + val isCurrentEditor = editor == editorWithSearchMatches + var countMatches = if (!isCurrentEditor || !countMatchesSetting) CountMatchesState.NoCountMatches else CountMatchesState.ShowCount + var currentMatchOffset = -1 // Try to keep existing highlights if possible. Update if hlsearch has changed or if the pattern has changed. // Force update for the situations where the text is the same, but the ignore case values have changed. // E.g., Use `*` to search for a word (which ignores smartcase), then use `/` to search for the same pattern, // which will match smartcase. Or changing the smartcase/ignorecase settings - if (shouldRemoveSearchHighlights(editor, pattern, showHighlights) || forceUpdate) { + val clearHighlights = shouldRemoveSearchHighlights(editor, pattern, showHighlights) + if (clearHighlights || forceUpdate) { removeSearchHighlights(editor) } @@ -131,35 +157,22 @@ private fun updateSearchHighlights( // hlsearch (+ incsearch/noincsearch) // Make sure the range fits this editor. Note that Vim will use the same range for all windows. E.g., given // `:1,5s/foo`, Vim will highlight all occurrences of `foo` in the first five lines of all visible windows - val vimEditor = editor.vim - val editorLastLine = vimEditor.lineCount() - 1 - val searchStartLine = searchRange?.startLine ?: 0 - val searchEndLine = (searchRange?.endLine ?: -1).coerceAtMost(editorLastLine) - if (searchStartLine <= editorLastLine) { - val results = - injector.searchHelper.findAll( - vimEditor, - pattern, - searchStartLine, - searchEndLine, - shouldIgnoreCase - ) - if (results.isNotEmpty()) { - // Only in incsearch is current editor not null, then check result size - val showHighlightsInEditor = currentEditor == null || results.size < maxhlduringincsearch - if (editor == currentEditor?.ij) { - val currentMatchIndex = findClosestMatch(results, initialOffset, count1, forwards) - currentMatchOffset = if (currentMatchIndex == -1) -1 else results[currentMatchIndex].startOffset - - if (!showHighlightsInEditor) { - // Always highlight at least the "current" match in the active editor - highlightSearchResults(editor, pattern, listOf(results[currentMatchIndex]), currentMatchOffset) - } + val results = findAllMatches(pattern, editor.vim, searchRange, shouldIgnoreCase) + if (results.isNotEmpty()) { + // Only in incsearch is current editor not null, then check result size + val showHighlightsInEditor = currentEditor == null || results.size < maxhlduringincsearch + if (editor == currentEditor?.ij) { + val currentMatchIndex = findClosestMatch(results, initialOffset, count1, forwards) + currentMatchOffset = if (currentMatchIndex == -1) -1 else results[currentMatchIndex].startOffset + + if (!showHighlightsInEditor) { + // Always highlight at least the "current" match in the active editor + highlightSearchResults(editor, pattern, listOf(results[currentMatchIndex]), currentMatchOffset) } + } - if (showHighlightsInEditor) { - highlightSearchResults(editor, pattern, results, currentMatchOffset) - } + if (showHighlightsInEditor) { + highlightSearchResults(editor, pattern, results, currentMatchOffset) } } editor.vimLastSearch = pattern @@ -189,11 +202,44 @@ private fun updateSearchHighlights( if (offset != null && editor === currentEditor?.ij) { currentMatchOffset = offset } + } else { + countMatches = CountMatchesState.MaybeClearCount } - if (editor === currentEditor?.ij) { + if (!isCurrentEditor) + return@forEach + + var editorCaretOffset = editor.vim.primaryCaret().offset + if (currentEditor?.ij != null) { currentEditorCurrentMatchOffset = currentMatchOffset + editorCaretOffset = currentEditorCurrentMatchOffset + } else if (newCaretPosition != null) { + editorCaretOffset = newCaretPosition + } + + if (countMatches == CountMatchesState.NoCountMatches) { + return@forEach + } + + // If any of the following hold we are still searching: + // - We just highlighted some search results (countMatches is not MaybeClear + // - We did not clear highlights ( + // - We are moving towards to a new position + if (countMatches == CountMatchesState.MaybeClearCount && clearHighlights && newCaretPosition == null) { + ExEntryPanel.getOrCreatePanelInstance().clearStatusText() + return@forEach + } + + // Search file for pattern, and determine total and position in results + val results = findAllMatches(pattern, editor.vim, searchRange, shouldIgnoreCase) + val patternIndex = if (results.isEmpty()) { + -1 + } else { + findClosestOrCurrentMatch(results, editorCaretOffset) } + + val countMessage = "[" + (patternIndex + 1) + "/" + results.size + "]" + ExEntryPanel.getOrCreatePanelInstance().setStatusText(editor, countMessage) } return currentEditorCurrentMatchOffset @@ -224,6 +270,23 @@ private fun shouldAddAllSearchHighlights(editor: Editor, newPattern: String?, hl return hlSearch && newPattern != null && newPattern != editor.vimLastSearch && newPattern != "" } +private fun findAllMatches(pattern: String, vimEditor: VimEditor, searchRange: LineRange?, shouldIgnoreCase: Boolean): List { + val editorLastLine = vimEditor.lineCount() - 1 + val searchStartLine = searchRange?.startLine ?: 0 + val searchEndLine = (searchRange?.endLine ?: -1).coerceAtMost(editorLastLine) + if (searchStartLine > editorLastLine) { + return listOf() + } + + return injector.searchHelper.findAll( + vimEditor, + pattern, + searchStartLine, + searchEndLine, + shouldIgnoreCase + ) +} + private fun findClosestMatch( results: List, initialOffset: Int, @@ -257,6 +320,23 @@ private fun findClosestMatch( return nextIndex % results.size } +private fun findClosestOrCurrentMatch( + results: List, + initialOffset: Int, +): Int { + if (results.isEmpty() || initialOffset == -1) { + return -1 + } + + val firstMatch = results.filter { it.endOffset >= initialOffset }.minByOrNull { it.endOffset } + if (firstMatch == null) { + // Results is not empty but there is no match before offset, we must be past the last match + return results.size - 1 + } + // Note that wrapping for the count does not make sense + return results.indexOfFirst { it.endOffset == firstMatch.endOffset } +} + internal fun highlightSearchResults( editor: Editor, pattern: String, diff --git a/src/main/java/com/maddyhome/idea/vim/newapi/IjVimSearchGroup.kt b/src/main/java/com/maddyhome/idea/vim/newapi/IjVimSearchGroup.kt index a095c91415..e9d1a7815e 100644 --- a/src/main/java/com/maddyhome/idea/vim/newapi/IjVimSearchGroup.kt +++ b/src/main/java/com/maddyhome/idea/vim/newapi/IjVimSearchGroup.kt @@ -81,8 +81,8 @@ open class IjVimSearchGroup : VimSearchGroupBase(), PersistentStateComponent