From f64c99c4063d711bb931e74d2c85475eb8f63721 Mon Sep 17 00:00:00 2001
From: Matt Ellis <m.t.ellis@gmail.com>
Date: Thu, 18 Apr 2024 08:46:09 +0100
Subject: [PATCH] Support incsearch highlighting for global command

Fixes VIM-2891
---
 .../idea/vim/ui/ex/ExEntryPanel.java          |  22 +-
 .../vimscript/model/commands/GlobalCommand.kt |  14 +-
 .../plugins/ideavim/group/SearchGroupTest.kt  | 456 +++++++++++++++++-
 .../jetbrains/plugins/ideavim/VimTestCase.kt  |   6 +-
 .../vim/vimscript/model/commands/Command.kt   |  55 ++-
 .../vimscript/model/commands/FileCommand.kt   |   2 +-
 6 files changed, 523 insertions(+), 32 deletions(-)

diff --git a/src/main/java/com/maddyhome/idea/vim/ui/ex/ExEntryPanel.java b/src/main/java/com/maddyhome/idea/vim/ui/ex/ExEntryPanel.java
index 5f9f9083b..94e8512fe 100644
--- a/src/main/java/com/maddyhome/idea/vim/ui/ex/ExEntryPanel.java
+++ b/src/main/java/com/maddyhome/idea/vim/ui/ex/ExEntryPanel.java
@@ -34,6 +34,7 @@ import com.maddyhome.idea.vim.regexp.CharPointer;
 import com.maddyhome.idea.vim.regexp.RegExp;
 import com.maddyhome.idea.vim.ui.ExPanelBorder;
 import com.maddyhome.idea.vim.vimscript.model.commands.Command;
+import com.maddyhome.idea.vim.vimscript.model.commands.GlobalCommand;
 import com.maddyhome.idea.vim.vimscript.model.commands.SubstituteCommand;
 import com.maddyhome.idea.vim.vimscript.parser.VimscriptParser;
 import org.jetbrains.annotations.Contract;
@@ -270,11 +271,13 @@ public class ExEntryPanel extends JPanel implements VimCommandLine {
       try {
         final Editor editor = entry.getEditor();
 
+        final String labelText = label.getText(); // Either '/', '?' or ':'boolean searchCommand = false;
+
         boolean searchCommand = false;
         LineRange searchRange = null;
-        char separator = label.getText().charAt(0);
+        char separator = labelText.charAt(0);
         String searchText = entry.getActualText();
-        if (label.getText().equals(":")) {
+        if (labelText.equals(":")) {
           if (searchText.isEmpty()) return;
           final Command command = getIncsearchCommand(searchText);
           if (command == null) {
@@ -287,14 +290,18 @@ public class ExEntryPanel extends JPanel implements VimCommandLine {
             separator = argument.charAt(0);
             searchText = argument.substring(1);
           }
-          if (searchText.length() == 0) {
-            // Reset back to the original search highlights after deleting a search from a substitution command.
+          if (!searchText.isEmpty()) {
+          searchRange = command.getLineRangeSafe(new IjVimEditor(editor));
+        }
+        if (searchText.isEmpty() || searchRange == null) {
+            // Reset back to the original search highlights after deleting a search from a substitution command.Or if
+            // there is no search range (because the user entered an invalid range, e.g. mark not set).
             // E.g. Highlight `whatever`, type `:%s/foo` + highlight `foo`, delete back to `:%s/` and reset highlights
             // back to `whatever`
             VimPlugin.getSearch().resetIncsearchHighlights();
+            resetCaretOffset(editor);
             return;
           }
-          searchRange = command.getLineRange(new IjVimEditor(editor));
         }
 
         // If we're showing highlights for the search command `/`, then the command builder will have a count already
@@ -302,7 +309,6 @@ public class ExEntryPanel extends JPanel implements VimCommandLine {
         // obviously won't be a count.
         int count1 = Math.max(1, KeyHandler.getInstance().getKeyHandlerState().getEditorCommandBuilder().getCount());
 
-        final String labelText = label.getText();
         if (labelText.equals("/") || labelText.equals("?") || searchCommand) {
           final boolean forwards = !labelText.equals("?");  // :s, :g, :v are treated as forwards
           final String pattern;
@@ -334,8 +340,8 @@ public class ExEntryPanel extends JPanel implements VimCommandLine {
       if (commandText == null) return null;
       try {
         final Command exCommand = VimscriptParser.INSTANCE.parseCommand(commandText);
-        // TODO: Add global, vglobal, smagic and snomagic here when the commands are supported
-        if (exCommand instanceof SubstituteCommand) {
+        // TODO: Add smagic and snomagic here if/when the commands are supported
+        if (exCommand instanceof SubstituteCommand || exCommand instanceof GlobalCommand) {
           return exCommand;
         }
       }
diff --git a/src/main/java/com/maddyhome/idea/vim/vimscript/model/commands/GlobalCommand.kt b/src/main/java/com/maddyhome/idea/vim/vimscript/model/commands/GlobalCommand.kt
index 42bf81f54..e52768e7d 100644
--- a/src/main/java/com/maddyhome/idea/vim/vimscript/model/commands/GlobalCommand.kt
+++ b/src/main/java/com/maddyhome/idea/vim/vimscript/model/commands/GlobalCommand.kt
@@ -39,19 +39,19 @@ import com.maddyhome.idea.vim.vimscript.model.ExecutionResult
  */
 @ExCommand(command = "g[lobal],v[global]")
 internal data class GlobalCommand(val range: Range, val argument: String, val invert: Boolean) : Command.SingleExecution(range, argument) {
+
+  init {
+    // Most commands have a default range of the current line ("."). Global has a default range of the whole file
+    defaultRange = "%"
+  }
+
   override val argFlags = flags(RangeFlag.RANGE_OPTIONAL, ArgumentFlag.ARGUMENT_OPTIONAL, Access.SELF_SYNCHRONIZED)
 
   override fun processCommand(editor: VimEditor, context: ExecutionContext, operatorArguments: OperatorArguments): ExecutionResult {
     var result: ExecutionResult = ExecutionResult.Success
     editor.removeSecondaryCarets()
     val caret = editor.currentCaret()
-
-    // For :g command the default range is %
-    val lineRange: LineRange = if (range.size() == 0) {
-      LineRange(0, editor.lineCount() - 1)
-    } else {
-      getLineRange(editor, caret)
-    }
+    val lineRange = getLineRange(editor, caret)
     if (!processGlobalCommand(editor, context, lineRange)) {
       result = ExecutionResult.Error
     }
diff --git a/src/test/java/org/jetbrains/plugins/ideavim/group/SearchGroupTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/group/SearchGroupTest.kt
index c8143fada..df48680a3 100644
--- a/src/test/java/org/jetbrains/plugins/ideavim/group/SearchGroupTest.kt
+++ b/src/test/java/org/jetbrains/plugins/ideavim/group/SearchGroupTest.kt
@@ -1657,7 +1657,7 @@ class SearchGroupTest : VimTestCase() {
     )
     enterCommand("set hlsearch incsearch")
 
-    enterSearch("and")
+    enterSearch("and")  // Moves the caret to "and" on the second line: (1, 10)
     typeText(":", "%s/roc", "<BS><BS><BS>")
 
     assertSearchHighlights(
@@ -1669,7 +1669,8 @@ class SearchGroupTest : VimTestCase() {
       """.trimMargin(),
     )
 
-    // TODO: Check caret position
+    // Make sure the caret is reset too
+    assertPosition(1, 10)
   }
 
   @Test
@@ -1683,7 +1684,7 @@ class SearchGroupTest : VimTestCase() {
     )
     enterCommand("set hlsearch incsearch")
 
-    enterSearch("and")
+    enterSearch("and")  // Moves the care to "and" on second line: (1, 10)
     typeText(":", "%s/ass", "<Esc>")
 
     assertSearchHighlights(
@@ -1695,9 +1696,456 @@ class SearchGroupTest : VimTestCase() {
       """.trimMargin(),
     )
 
-    // TODO: Check caret position
+    // Make sure the caret is reset too
+    assertPosition(1, 10)
   }
 
+  // global
+  @Test
+  fun `test incsearch highlights for global command with range`() {
+    configureByText(
+      """I found it in a legendary land
+           |${c}all rocks and lavender and tufted grass,
+           |where it was settled on some sodden sand
+           |hard by the torrent of a mountain pass.
+      """.trimMargin(),
+    )
+    enterCommand("set hlsearch incsearch")
+
+    typeText(":", "%g/and")
+
+    assertSearchHighlights(
+      "and",
+      """I found it in a legendary l‷and‴
+           |all rocks «and» lavender «and» tufted grass,
+           |where it was settled on some sodden s«and»
+           |hard by the torrent of a mountain pass.
+      """.trimMargin(),
+    )
+  }
+
+  @Test
+  fun `test incsearch highlights for global command in whole file with default range`() {
+    configureByText(
+      """I found it in a legendary land
+           |${c}all rocks and lavender and tufted grass,
+           |where it was settled on some sodden sand
+           |hard by the torrent of a mountain pass.
+      """.trimMargin(),
+    )
+    enterCommand("set hlsearch incsearch")
+
+    typeText(":", "g/and")
+
+    assertSearchHighlights(
+      "and",
+      """I found it in a legendary l‷and‴
+           |all rocks «and» lavender «and» tufted grass,
+           |where it was settled on some sodden s«and»
+           |hard by the torrent of a mountain pass.
+      """.trimMargin(),
+    )
+  }
+
+  @Test
+  fun `test incsearch highlights for global-bang command`() {
+    configureByText(
+      """I found it in a legendary land
+           |${c}all rocks and lavender and tufted grass,
+           |where it was settled on some sodden sand
+           |hard by the torrent of a mountain pass.
+      """.trimMargin(),
+    )
+    enterCommand("set hlsearch incsearch")
+
+    typeText(":", "%g!/and")
+
+    assertSearchHighlights(
+      "and",
+      """I found it in a legendary l‷and‴
+           |all rocks «and» lavender «and» tufted grass,
+           |where it was settled on some sodden s«and»
+           |hard by the torrent of a mountain pass.
+      """.trimMargin(),
+    )
+  }
+
+  @Test
+  fun `test incsearch only highlights for global command after valid argument`() {
+    configureByText(
+      """I found it in a legendary land
+           |${c}all rocks and lavender and tufted grass,
+           |where it was settled on some sodden sand
+           |hard by the torrent of a mountain pass.
+      """.trimMargin(),
+    )
+    enterCommand("set hlsearch incsearch")
+
+    // E.g. don't remove highlights when trying to type :goto
+    enterSearch("and")
+    typeText(":g")
+
+    assertSearchHighlights(
+      "and",
+      """I found it in a legendary l«and»
+           |all rocks «and» lavender «and» tufted grass,
+           |where it was settled on some sodden s«and»
+           |hard by the torrent of a mountain pass.
+      """.trimMargin(),
+    )
+  }
+
+  @Test
+  fun `test incsearch highlights for global command only highlights in range`() {
+    configureByText(
+      """I found it in a legendary land
+           |all rocks and lavender and tufted grass,
+           |where it was settled on some sodden sand
+           |${c}hard by the torrent and rush of a mountain pass.
+      """.trimMargin(),
+    )
+    enterCommand("set hlsearch incsearch")
+
+    typeText(":", "2,3g/and")
+
+    assertSearchHighlights(
+      "and",
+      """I found it in a legendary land
+           |all rocks ‷and‴ lavender «and» tufted grass,
+           |where it was settled on some sodden s«and»
+           |hard by the torrent and rush of a mountain pass.
+      """.trimMargin(),
+    )
+  }
+
+  @Test
+  fun `test incsearch for global command starts at beginning of range not caret position`() {
+    configureByText(
+      """I found it in a legendary land
+           |all rocks and lavender and tufted grass,
+           |where it was settled on some sodden sand
+           |hard by the torrent of a mountain pass.
+           |${c}I found it in a legendary land
+           |all rocks and lavender and tufted grass,
+           |where it was settled on some sodden sand
+           |hard by the torrent of a mountain pass.
+           |I found it in a legendary land
+           |all rocks and lavender and tufted grass,
+           |where it was settled on some sodden sand
+           |hard by the torrent of a mountain pass.
+      """.trimMargin(),
+    )
+    enterCommand("set hlsearch incsearch")
+
+    typeText(":", "2,8g/and")
+
+    assertSearchHighlights(
+      "and",
+      """I found it in a legendary land
+           |all rocks ‷and‴ lavender «and» tufted grass,
+           |where it was settled on some sodden s«and»
+           |hard by the torrent of a mountain pass.
+           |I found it in a legendary l«and»
+           |all rocks «and» lavender «and» tufted grass,
+           |where it was settled on some sodden s«and»
+           |hard by the torrent of a mountain pass.
+           |I found it in a legendary land
+           |all rocks and lavender and tufted grass,
+           |where it was settled on some sodden sand
+           |hard by the torrent of a mountain pass.
+      """.trimMargin(),
+    )
+  }
+
+  @Test
+  fun `test incsearch highlights for global command clears highlights on backspace`() {
+    configureByText(
+      """I found it in a legendary land
+             |${c}all rocks and lavender and tufted grass,
+             |where it was settled on some sodden sand
+             |hard by the torrent of a mountain pass.
+      """.trimMargin(),
+    )
+    enterCommand("set hlsearch incsearch")
+
+    typeText(":", "g/and", "<BS><BS><BS>")
+
+    assertSearchHighlights(
+      "and",
+      """I found it in a legendary land
+             |all rocks and lavender and tufted grass,
+             |where it was settled on some sodden sand
+             |hard by the torrent of a mountain pass.
+      """.trimMargin(),
+    )
+  }
+
+  @Test
+  fun `test incsearch highlights for global command resets highlights on backspace`() {
+    configureByText(
+      """I found it in a legendary land
+           |${c}all rocks and lavender and tufted grass,
+           |where it was settled on some sodden sand
+           |hard by the torrent of a mountain pass.
+      """.trimMargin(),
+    )
+    enterCommand("set hlsearch incsearch")
+
+    enterSearch("and")  // Moves the caret to "and" on the second line: (1, 10)
+    typeText(":", "g/roc", "<BS><BS><BS>")
+
+    assertSearchHighlights(
+      "and",
+      """I found it in a legendary l«and»
+           |all rocks «and» lavender «and» tufted grass,
+           |where it was settled on some sodden s«and»
+           |hard by the torrent of a mountain pass.
+      """.trimMargin(),
+    )
+
+    // Make sure the caret is reset too
+    assertPosition(1, 10)
+  }
+
+  @Test
+  fun `test cancelling incsearch highlights for global command shows previous highlights`() {
+    configureByText(
+      """I found it in a legendary land
+           |${c}all rocks and lavender and tufted grass,
+           |where it was settled on some sodden sand
+           |hard by the torrent of a mountain pass.
+      """.trimMargin(),
+    )
+    enterCommand("set hlsearch incsearch")
+
+    enterSearch("and")  // Moves the caret to "and" on the second line (1, 10)
+    typeText(":", "g/ass", "<Esc>")
+
+    assertSearchHighlights(
+      "and",
+      """I found it in a legendary l«and»
+           |all rocks «and» lavender «and» tufted grass,
+           |where it was settled on some sodden s«and»
+           |hard by the torrent of a mountain pass.
+      """.trimMargin(),
+    )
+
+    // Make sure the caret is reset too
+    assertPosition(1, 10)
+  }
+
+  // vglobal
+  @Test
+  fun `test incsearch highlights for vglobal command with range`() {
+    configureByText(
+      """I found it in a legendary land
+           |${c}all rocks and lavender and tufted grass,
+           |where it was settled on some sodden sand
+           |hard by the torrent of a mountain pass.
+      """.trimMargin(),
+    )
+    enterCommand("set hlsearch incsearch")
+
+    typeText(":", "%v/and")
+
+    assertSearchHighlights(
+      "and",
+      """I found it in a legendary l‷and‴
+           |all rocks «and» lavender «and» tufted grass,
+           |where it was settled on some sodden s«and»
+           |hard by the torrent of a mountain pass.
+      """.trimMargin(),
+    )
+  }
+
+  @Test
+  fun `test incsearch highlights for vglobal command in whole file with default range`() {
+    configureByText(
+      """I found it in a legendary land
+           |${c}all rocks and lavender and tufted grass,
+           |where it was settled on some sodden sand
+           |hard by the torrent of a mountain pass.
+      """.trimMargin(),
+    )
+    enterCommand("set hlsearch incsearch")
+
+    typeText(":", "v/and")
+
+    assertSearchHighlights(
+      "and",
+      """I found it in a legendary l‷and‴
+           |all rocks «and» lavender «and» tufted grass,
+           |where it was settled on some sodden s«and»
+           |hard by the torrent of a mountain pass.
+      """.trimMargin(),
+    )
+  }
+
+  @Test
+  fun `test incsearch only highlights for vglobal command after valid argument`() {
+    configureByText(
+      """I found it in a legendary land
+           |${c}all rocks and lavender and tufted grass,
+           |where it was settled on some sodden sand
+           |hard by the torrent of a mountain pass.
+      """.trimMargin(),
+    )
+    enterCommand("set hlsearch incsearch")
+
+    // E.g. don't remove highlights when trying to type :vmap
+    enterSearch("and")
+    typeText(":v")
+
+    assertSearchHighlights(
+      "and",
+      """I found it in a legendary l«and»
+           |all rocks «and» lavender «and» tufted grass,
+           |where it was settled on some sodden s«and»
+           |hard by the torrent of a mountain pass.
+      """.trimMargin(),
+    )
+  }
+
+  @Test
+  fun `test incsearch highlights for vglobal command only highlights in range`() {
+    configureByText(
+      """I found it in a legendary land
+           |all rocks and lavender and tufted grass,
+           |where it was settled on some sodden sand
+           |${c}hard by the torrent and rush of a mountain pass.
+      """.trimMargin(),
+    )
+    enterCommand("set hlsearch incsearch")
+
+    typeText(":", "2,3v/and")
+
+    assertSearchHighlights(
+      "and",
+      """I found it in a legendary land
+           |all rocks ‷and‴ lavender «and» tufted grass,
+           |where it was settled on some sodden s«and»
+           |hard by the torrent and rush of a mountain pass.
+      """.trimMargin(),
+    )
+  }
+
+  @Test
+  fun `test incsearch for vglobal command starts at beginning of range not caret position`() {
+    configureByText(
+      """I found it in a legendary land
+           |all rocks and lavender and tufted grass,
+           |where it was settled on some sodden sand
+           |hard by the torrent of a mountain pass.
+           |${c}I found it in a legendary land
+           |all rocks and lavender and tufted grass,
+           |where it was settled on some sodden sand
+           |hard by the torrent of a mountain pass.
+           |I found it in a legendary land
+           |all rocks and lavender and tufted grass,
+           |where it was settled on some sodden sand
+           |hard by the torrent of a mountain pass.
+      """.trimMargin(),
+    )
+    enterCommand("set hlsearch incsearch")
+
+    typeText(":", "2,8v/and")
+
+    assertSearchHighlights(
+      "and",
+      """I found it in a legendary land
+           |all rocks ‷and‴ lavender «and» tufted grass,
+           |where it was settled on some sodden s«and»
+           |hard by the torrent of a mountain pass.
+           |I found it in a legendary l«and»
+           |all rocks «and» lavender «and» tufted grass,
+           |where it was settled on some sodden s«and»
+           |hard by the torrent of a mountain pass.
+           |I found it in a legendary land
+           |all rocks and lavender and tufted grass,
+           |where it was settled on some sodden sand
+           |hard by the torrent of a mountain pass.
+      """.trimMargin(),
+    )
+  }
+
+  @Test
+  fun `test incsearch highlights for vglobal command clears highlights on backspace`() {
+    configureByText(
+      """I found it in a legendary land
+             |${c}all rocks and lavender and tufted grass,
+             |where it was settled on some sodden sand
+             |hard by the torrent of a mountain pass.
+      """.trimMargin(),
+    )
+    enterCommand("set hlsearch incsearch")
+
+    typeText(":", "v/and", "<BS><BS><BS>")
+
+    assertSearchHighlights(
+      "and",
+      """I found it in a legendary land
+             |all rocks and lavender and tufted grass,
+             |where it was settled on some sodden sand
+             |hard by the torrent of a mountain pass.
+      """.trimMargin(),
+    )
+  }
+
+  @Test
+  fun `test incsearch highlights for vglobal command resets highlights on backspace`() {
+    configureByText(
+      """I found it in a legendary land
+           |${c}all rocks and lavender and tufted grass,
+           |where it was settled on some sodden sand
+           |hard by the torrent of a mountain pass.
+      """.trimMargin(),
+    )
+    enterCommand("set hlsearch incsearch")
+
+    enterSearch("and")  // Moves the caret to "and" on the second line: (1, 10)
+    typeText(":", "v/roc", "<BS><BS><BS>")
+
+    assertSearchHighlights(
+      "and",
+      """I found it in a legendary l«and»
+           |all rocks «and» lavender «and» tufted grass,
+           |where it was settled on some sodden s«and»
+           |hard by the torrent of a mountain pass.
+      """.trimMargin(),
+    )
+
+    // Make sure the caret is reset too
+    assertPosition(1, 10)
+  }
+
+  @Test
+  fun `test cancelling incsearch highlights for vglobal command shows previous highlights`() {
+    configureByText(
+      """I found it in a legendary land
+           |${c}all rocks and lavender and tufted grass,
+           |where it was settled on some sodden sand
+           |hard by the torrent of a mountain pass.
+      """.trimMargin(),
+    )
+    enterCommand("set hlsearch incsearch")
+
+    enterSearch("and")  // Moves the caret to "and" on the second line: (1, 10)
+    typeText(":", "v/ass", "<Esc>")
+
+    assertSearchHighlights(
+      "and",
+      """I found it in a legendary l«and»
+           |all rocks «and» lavender «and» tufted grass,
+           |where it was settled on some sodden s«and»
+           |hard by the torrent of a mountain pass.
+      """.trimMargin(),
+    )
+
+    // Make sure the caret is reset too
+    assertPosition(1, 10)
+  }
+
+
   @Test
   fun `test incsearch updates selection when started in Visual mode`() {
     doTest(
diff --git a/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt b/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt
index 50102ba53..75a5a506b 100644
--- a/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt
+++ b/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt
@@ -59,7 +59,7 @@ import com.maddyhome.idea.vim.api.setToggleOption
 import com.maddyhome.idea.vim.api.visualLineToBufferLine
 import com.maddyhome.idea.vim.command.MappingMode
 import com.maddyhome.idea.vim.ex.ExException
-import com.maddyhome.idea.vim.ex.ExOutputModel.Companion.getInstance
+import com.maddyhome.idea.vim.ex.ExOutputModel
 import com.maddyhome.idea.vim.group.EffectiveIjOptions
 import com.maddyhome.idea.vim.group.GlobalIjOptions
 import com.maddyhome.idea.vim.group.IjOptions
@@ -658,14 +658,14 @@ abstract class VimTestCase {
   }
 
   fun assertExOutput(expected: String) {
-    val actual = getInstance(fixture.editor).text
+    val actual = ExOutputModel.getInstance(fixture.editor).text
     assertNotNull(actual, "No Ex output")
     assertEquals(expected, actual)
     NeovimTesting.typeCommand("<esc>", testInfo, fixture.editor)
   }
 
   fun assertNoExOutput() {
-    val actual = getInstance(fixture.editor).text
+    val actual = ExOutputModel.getInstance(fixture.editor).text
     assertNull(actual, "Ex output not null")
   }
 
diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/vimscript/model/commands/Command.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/vimscript/model/commands/Command.kt
index 27f390427..5ae0926d6 100644
--- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/vimscript/model/commands/Command.kt
+++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/vimscript/model/commands/Command.kt
@@ -27,11 +27,14 @@ import com.maddyhome.idea.vim.vimscript.model.ExecutionResult
 import com.maddyhome.idea.vim.vimscript.model.VimLContext
 import java.util.*
 
-public sealed class Command(protected var commandRange: Range, public val commandArgument: String) : Executable {
+public sealed class Command(private val commandRange: Range, public val commandArgument: String) : Executable {
   override lateinit var vimContext: VimLContext
   override lateinit var rangeInScript: TextRange
 
   protected abstract val argFlags: CommandHandlerFlags
+
+  protected var defaultRange: String = "."
+
   private var nextArgumentTokenOffset = 0
   private val logger = vimLogger<Command>()
 
@@ -54,12 +57,13 @@ public sealed class Command(protected var commandRange: Range, public val comman
 
   @Throws(ExException::class)
   override fun execute(editor: VimEditor, context: ExecutionContext): ExecutionResult {
-    checkRanges(editor)
-    checkArgument()
+    validate(editor)
+
     if (editor.nativeCarets().any { it.hasSelection() } && Flag.SAVE_VISUAL !in argFlags.flags) {
       editor.removeSelection()
       editor.removeSecondaryCarets()
     }
+
     if (argFlags.access == Access.WRITABLE && !editor.isDocumentWritable()) {
       logger.info("Trying to modify readonly document")
       return ExecutionResult.Error
@@ -101,6 +105,11 @@ public sealed class Command(protected var commandRange: Range, public val comman
     return result
   }
 
+  private fun validate(editor: VimEditor) {
+    checkRanges(editor)
+    checkArgument()
+  }
+
   private fun checkRanges(editor: VimEditor) {
     if (RangeFlag.RANGE_FORBIDDEN == argFlags.rangeFlag && commandRange.size() != 0) {
       // Some commands (e.g. `:file`) throw "E474: Invalid argument" instead, while e.g. `:3ascii` throws E481
@@ -115,9 +124,16 @@ public sealed class Command(protected var commandRange: Range, public val comman
 
     // If a range isn't specified, the default range for most commands is the current line ("."). If the command is
     // expecting a count instead of a range, the default would be a count of 1, represented as the range "1".
+    // GlobalCommand is the only other command that has a different default. We could introduce another RangeFlag
+    // (and maybe make them an enum set so it can be optional and whole-file-range), or set it
+    // TODO: This is initialisation, not validation
+    // It would be nice to do this in the constructor, but argFlags is abstract, so we can't access it
     if (RangeFlag.RANGE_IS_COUNT == argFlags.rangeFlag) {
       commandRange.defaultRange = "1"
     }
+    else {
+      commandRange.defaultRange = defaultRange
+    }
   }
 
   private fun checkArgument() {
@@ -218,6 +234,8 @@ public sealed class Command(protected var commandRange: Range, public val comman
 
   private fun getNextArgumentToken() = commandArgument.substring(nextArgumentTokenOffset).trimStart()
 
+  protected fun isRangeSpecified(): Boolean = commandRange.size() > 0
+
   /**
    * Return the last line of the range as a count, one-based
    */
@@ -276,11 +294,30 @@ public sealed class Command(protected var commandRange: Range, public val comman
       ?: throw exExceptionMessage(Msg.e_invrange) // E16: Invalid range
   }
 
-  public fun getLine(editor: VimEditor): Int = getLine(editor, editor.currentCaret())
-  public fun getLine(editor: VimEditor, caret: VimCaret): Int = commandRange.getLine(editor, caret)
+  protected fun getLine(editor: VimEditor): Int = getLine(editor, editor.currentCaret())
+  protected fun getLine(editor: VimEditor, caret: VimCaret): Int = commandRange.getLine(editor, caret)
 
-  public fun getLineRange(editor: VimEditor): LineRange = getLineRange(editor, editor.currentCaret())
-  public fun getLineRange(editor: VimEditor, caret: VimCaret): LineRange = commandRange.getLineRange(editor, caret)
+  protected fun getLineRange(editor: VimEditor): LineRange = getLineRange(editor, editor.currentCaret())
+  protected fun getLineRange(editor: VimEditor, caret: VimCaret): LineRange = commandRange.getLineRange(editor, caret)
+
+  /**
+   * Accessor method purely for incsearch
+   *
+   * Ensures that the range and argument have been correctly initialised and validated, specifically that the default
+   * range has been set. Any validation errors are swallowed and ignored.
+   *
+   * It would be cleaner to move incsearch handling into the search Command instances, which could access this data
+   * safely.
+   */
+  public fun getLineRangeSafe(editor: VimEditor): LineRange? {
+    try {
+      validate(editor)
+    }
+    catch (t: Throwable) {
+      return null
+    }
+    return getLineRange(editor)
+  }
 
   /**
    * Get the line range using the optional count argument
@@ -291,8 +328,8 @@ public sealed class Command(protected var commandRange: Range, public val comman
    * The `{count}` argument must be a simple integer, with no trailing characters. This function will fail with "E488:
    * Trailing characters" otherwise.
    */
-  public fun getLineRangeWithCount(editor: VimEditor, caret: VimCaret): LineRange {
-    val lineRange = commandRange.getLineRange(editor, caret)
+  protected fun getLineRangeWithCount(editor: VimEditor, caret: VimCaret): LineRange {
+    val lineRange = getLineRange(editor, caret)
     return getCountFromArgument()?.let { count ->
       LineRange(lineRange.endLine, lineRange.endLine + count - 1)
     } ?: lineRange
diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/vimscript/model/commands/FileCommand.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/vimscript/model/commands/FileCommand.kt
index b377e02b2..8b34868c7 100644
--- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/vimscript/model/commands/FileCommand.kt
+++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/vimscript/model/commands/FileCommand.kt
@@ -28,7 +28,7 @@ public data class FileCommand(val range: Range, val argument: String) : Command.
     // TODO: Support the `:file {name}` argument to set the name of the current file
     // Note that `:file` doesn't really support a range or count. But `:0file` is support to remove the current file
     // name. We don't support either of these features, but by accepting a range/count, we can report the right error
-    if (commandRange.size() != 0) {
+    if (isRangeSpecified()) {
       throw ExException("E474: Invalid argument")
     }