From aae0d825e799b4db2199661afd7c42b2955d9a6e Mon Sep 17 00:00:00 2001
From: Alex Plate <aleksei.plate@jetbrains.com>
Date: Mon, 5 Feb 2024 19:21:09 +0200
Subject: [PATCH] Move the ideavim-sneak plugin into IdeaVim

The author of the original plugin announced the deprecation of the plugin.
However, we've got an approval to move the sources into IdeaVim and continue the development.

Original repo: https://github.com/Mishkun/ideavim-sneak
Approval: https://twitter.com/ideavim/status/1754512214344478939
---
 ThirdPartyLicenses.md                         |   5 +
 doc/IdeaVim Plugins.md                        |  15 +-
 doc/images/sneakIcon.svg                      |  28 ++
 .../vim/extension/VimExtensionRegistrar.kt    |   5 +
 .../extension/sneak/IdeaVimSneakExtension.kt  | 291 ++++++++++++++++++
 .../META-INF/includes/VimExtensions.xml       |   8 +
 .../implementation/commands/SetCommandTest.kt |  28 +-
 .../commands/SetglobalCommandTest.kt          |  28 +-
 .../commands/SetlocalCommandTest.kt           |  28 +-
 .../extension/sneak/IdeaVimSneakTest.kt       | 165 ++++++++++
 10 files changed, 557 insertions(+), 44 deletions(-)
 create mode 100644 doc/images/sneakIcon.svg
 create mode 100644 src/main/java/com/maddyhome/idea/vim/extension/sneak/IdeaVimSneakExtension.kt
 create mode 100644 src/test/java/org/jetbrains/plugins/ideavim/extension/sneak/IdeaVimSneakTest.kt

diff --git a/ThirdPartyLicenses.md b/ThirdPartyLicenses.md
index 112cd06cf..bf1784094 100644
--- a/ThirdPartyLicenses.md
+++ b/ThirdPartyLicenses.md
@@ -84,3 +84,8 @@ IV)  It is not allowed to remove this license from the distribution of the Vim
      license for previous Vim releases instead of the license that they came
      with, at your option.
 ```
+
+---
+
+File [sneakIcon.png](doc/images/sneakIcon.svg), which is originally an icon of the ideavim-sneak plugin,
+is merged icons of IdeaVim plugin and a random sneaker by FreePic from flaticon.com.
diff --git a/doc/IdeaVim Plugins.md b/doc/IdeaVim Plugins.md
index f2478e027..8e1d6b546 100644
--- a/doc/IdeaVim Plugins.md	
+++ b/doc/IdeaVim Plugins.md	
@@ -44,16 +44,21 @@ All commands with the mappings are supported. See the [full list of supported co
 
 <details>
 <summary><h2>sneak</h2></summary>
-   
+
+<img src="images/sneakIcon.svg" width="80" height="80" alt="icon"/>  
+
+By [Mikhail Levchenko](https://github.com/Mishkun)  
+Original repository with the plugin: https://github.com/Mishkun/ideavim-sneak  
 Original plugin: [vim-sneak](https://github.com/justinmk/vim-sneak).
    
 ### Setup:
-- Install [IdeaVim-sneak](https://plugins.jetbrains.com/plugin/15348-ideavim-sneak) plugin.
-- Add the following command to `~/.ideavimrc`: `set sneak`
+- Add the following command to `~/.ideavimrc`: `Plug 'justinmk/vim-sneak'`
    
 ### Instructions
-   
-See the [docs](https://github.com/Mishkun/ideavim-sneak#usage)
+
+* Type `s` and two chars to start sneaking in forward direction
+* Type `S` and two chars to start sneaking in backward direction
+* Type `;` or `,` to proceed with sneaking just as if you were using `f` or `t` commands
 
 </details>
 
diff --git a/doc/images/sneakIcon.svg b/doc/images/sneakIcon.svg
new file mode 100644
index 000000000..79dc30b65
--- /dev/null
+++ b/doc/images/sneakIcon.svg
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg viewBox="386.498 234 32 32" xmlns="http://www.w3.org/2000/svg">
+  <defs>
+    <linearGradient id="ideavim_plugin-a" x1="-6.748%" x2="47.286%" y1="33.61%" y2="85.907%">
+      <stop offset="0" stop-color="#3BEA62"/>
+      <stop offset="1" stop-color="#087CFA"/>
+    </linearGradient>
+  </defs>
+  <g transform="matrix(1.238978, 0.90017, -0.90017, 1.238978, 131.776901, -422.953003)" style="">
+    <path d="M 399.962 247.648 C 399.207 246.894 399.147 246.318 399.692 245.453 C 400.237 244.588 401.955 245.886 401.955 245.886 L 401.955 250.737" style="fill: rgb(248, 245, 231);"/>
+    <path d="M 413.846 253.602 C 413.846 255.077 411.827 256.134 409.392 256.134 L 396.381 256.134 C 395.211 256.134 394.232 256.003 393.433 255.817 C 391.496 255.367 390.613 254.596 390.613 254.596 L 391.478 252.513 L 393.607 252.617 Z M 413.846 253.602" fill="#cce6f6" style=""/>
+    <path d="M 413.846 253.602 C 413.846 253.602 411.475 254.468 408.27 254.468 C 405.94 254.468 398.116 253.433 394.023 252.869 C 392.488 252.658 391.478 252.512 391.478 252.512 C 390.864 251.662 392.078 248.741 392.758 247.263 C 393.118 246.484 393.85 245.929 394.703 245.83 C 394.782 245.82 394.861 245.815 394.939 245.815 C 395.369 245.815 395.645 246.532 396.059 247.225 C 396.446 247.877 396.955 248.507 397.823 248.507 C 399.617 248.507 401.955 245.886 401.955 245.886 C 406.544 249.03 410.097 250.43 410.097 250.43 C 410.097 250.43 413.061 250.446 413.782 251.167 C 414.503 251.888 414.422 253.218 413.846 253.602 Z M 413.846 253.602" style="fill: rgb(8, 124, 250);"/>
+    <path d="M 394.023 252.869 L 393.433 255.817 C 391.496 255.367 390.613 254.596 390.613 254.596 L 391.478 252.513 L 393.607 252.617 Z M 394.023 252.869" fill="#9dcae0" style=""/>
+    <path d="M 396.059 247.225 C 395.073 245.986 393.193 250.255 394.023 252.869 C 392.488 252.658 391.478 252.513 391.478 252.513 C 390.864 251.662 392.078 248.741 392.758 247.263 C 393.118 246.484 393.85 245.929 394.703 245.83 C 394.782 245.82 394.861 245.815 394.939 245.815 C 395.369 245.815 395.645 246.532 396.059 247.225 Z M 396.059 247.225" style="fill: rgb(14, 112, 142);"/>
+    <path d="M 403.527 246.924 L 399.174 251.768 C 397.7 250.892 395.578 250.174 394.016 249.717 C 392.843 249.372 391.985 249.174 391.959 249.168 C 392.108 248.769 392.268 248.379 392.421 248.022 L 394.341 248.65 L 394.884 248.827 C 395.509 249.031 396.192 248.95 396.753 248.608 L 397.153 248.363 C 397.35 248.454 397.572 248.507 397.823 248.507 C 399.617 248.507 401.955 245.886 401.955 245.886 C 402.494 246.256 403.02 246.601 403.527 246.924 Z M 403.527 246.924" style="fill: rgb(59, 234, 98);"/>
+    <path d="M 413.847 253.602 C 413.847 253.602 411.475 254.468 408.27 254.468 C 407.586 254.468 406.426 254.378 405.025 254.238 L 405.025 254.237 C 405.025 253.495 406.924 251.743 408.366 251.616 C 408.623 252.865 410.097 252.512 411.219 252.128 C 412.341 251.743 413.783 251.167 413.783 251.167 C 414.503 251.888 414.422 253.218 413.847 253.602 Z M 413.847 253.602" style="fill: rgb(8, 124, 250);"/>
+    <path d="M 394.341 248.65 C 394.214 248.978 394.103 249.339 394.016 249.717 C 392.843 249.372 391.985 249.174 391.959 249.168 C 392.108 248.769 392.268 248.379 392.421 248.022 Z M 394.341 248.65" style="fill: rgb(37, 187, 163);"/>
+    <path d="M 408.366 251.616 C 406.924 251.743 405.025 253.495 405.025 254.237 L 405.025 254.238 C 403.784 254.113 402.355 253.948 400.899 253.77 C 400.899 253.051 400.191 252.372 399.174 251.768 L 399.174 251.768 L 403.528 246.924 C 407.331 249.34 410.097 250.43 410.097 250.43 C 410.77 251.102 409.809 251.487 408.366 251.616 Z M 408.366 251.616" style="fill: rgb(248, 245, 231);"/>
+    <polygon fill="url(#ideavim_plugin-a)" fill-rule="evenodd" points="406.356 248.463 403.261 252.616 402.183 248.212 400.984 249.899 401.999 254.236 403.927 254.176 407.639 249.062" style="" transform="matrix(0.994522, 0.104529, -0.104529, 0.994522, 28.475005, -40.88594)"/>
+    <g fill="#fb6572" transform="matrix(0.046265, 0, 0, 0.046265, 390.612823, 245.155533)" style="">
+      <path d="m288.839844 72.65625c-2.007813 0-4.011719-.761719-5.542969-2.292969-3.058594-3.0625-3.058594-8.023437 0-11.082031l20.089844-20.089844c3.0625-3.058594 8.023437-3.058594 11.082031 0 3.058594 3.0625 3.0625 8.023438 0 11.082032l-20.089844 20.089843c-1.527344 1.53125-3.535156 2.292969-5.539062 2.292969zm0 0" style="fill: rgb(56, 228, 105);"/>
+      <path d="m314.589844 87.082031c-2.007813 0-4.011719-.765625-5.542969-2.296875-3.0625-3.058594-3.0625-8.019531 0-11.082031l20.089844-20.085937c3.0625-3.058594 8.023437-3.058594 11.082031 0 3.058594 3.0625 3.058594 8.023437 0 11.082031l-20.089844 20.085937c-1.527344 1.53125-3.535156 2.296875-5.539062 2.296875zm0 0" style="fill: rgb(59, 233, 100);"/>
+      <path d="m340.339844 101.507812c-2.007813 0-4.011719-.765624-5.542969-2.296874-3.058594-3.058594-3.058594-8.023438 0-11.082032l20.089844-20.085937c3.0625-3.0625 8.023437-3.0625 11.082031 0 3.058594 3.058593 3.0625 8.023437 0 11.082031l-20.089844 20.085938c-1.527344 1.53125-3.535156 2.296874-5.539062 2.296874zm0 0" style="fill: rgb(59, 233, 100);"/>
+      <path d="m366.089844 115.929688c-2.003906 0-4.011719-.761719-5.539063-2.292969-3.0625-3.0625-3.0625-8.023438 0-11.082031l20.085938-20.089844c3.0625-3.058594 8.023437-3.058594 11.082031 0 3.0625 3.0625 3.0625 8.023437 0 11.082031l-20.085938 20.089844c-1.53125 1.53125-3.535156 2.292969-5.542968 2.292969zm0 0" style="fill: rgb(59, 233, 100);"/>
+    </g>
+    <path d="M 401.925 247.748 C 401.761 247.748 401.611 247.629 401.573 247.469 C 401.536 247.312 401.611 247.144 401.753 247.067 C 402 246.933 402.318 247.147 402.286 247.426 C 402.265 247.606 402.107 247.748 401.925 247.748 Z M 401.925 247.748" fill="#1e2628" style=""/>
+  </g>
+</svg>
\ No newline at end of file
diff --git a/src/main/java/com/maddyhome/idea/vim/extension/VimExtensionRegistrar.kt b/src/main/java/com/maddyhome/idea/vim/extension/VimExtensionRegistrar.kt
index 3b42bdc0d..445c6a36a 100644
--- a/src/main/java/com/maddyhome/idea/vim/extension/VimExtensionRegistrar.kt
+++ b/src/main/java/com/maddyhome/idea/vim/extension/VimExtensionRegistrar.kt
@@ -53,6 +53,11 @@ internal object VimExtensionRegistrar : VimExtensionRegistrator {
   @Synchronized
   private fun registerExtension(extensionBean: ExtensionBeanClass) {
     val name = extensionBean.name ?: extensionBean.instance.name
+    if (name == "sneak" && extensionBean.name == null) {
+      // Filter out the old ideavim-sneak extension that used to be a separate plugin
+      // https://github.com/Mishkun/ideavim-sneak
+      return
+    }
     if (name in registeredExtensions) return
 
     registeredExtensions.add(name)
diff --git a/src/main/java/com/maddyhome/idea/vim/extension/sneak/IdeaVimSneakExtension.kt b/src/main/java/com/maddyhome/idea/vim/extension/sneak/IdeaVimSneakExtension.kt
new file mode 100644
index 000000000..2bde8d17b
--- /dev/null
+++ b/src/main/java/com/maddyhome/idea/vim/extension/sneak/IdeaVimSneakExtension.kt
@@ -0,0 +1,291 @@
+/*
+ * 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.extension.sneak
+
+import com.intellij.openapi.actionSystem.DataContext
+import com.intellij.openapi.application.ApplicationManager
+import com.intellij.openapi.editor.Editor
+import com.intellij.openapi.editor.ScrollType
+import com.intellij.openapi.editor.colors.EditorColors
+import com.intellij.openapi.editor.markup.EffectType
+import com.intellij.openapi.editor.markup.HighlighterLayer
+import com.intellij.openapi.editor.markup.HighlighterTargetArea
+import com.intellij.openapi.editor.markup.RangeHighlighter
+import com.intellij.openapi.editor.markup.TextAttributes
+import com.intellij.openapi.util.Disposer
+import com.maddyhome.idea.vim.VimProjectService
+import com.maddyhome.idea.vim.api.ExecutionContext
+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.command.MappingMode
+import com.maddyhome.idea.vim.command.OperatorArguments
+import com.maddyhome.idea.vim.common.TextRange
+import com.maddyhome.idea.vim.extension.ExtensionHandler
+import com.maddyhome.idea.vim.extension.VimExtension
+import com.maddyhome.idea.vim.extension.VimExtensionFacade
+import com.maddyhome.idea.vim.extension.VimExtensionHandler
+import com.maddyhome.idea.vim.helper.StrictMode
+import com.maddyhome.idea.vim.newapi.ij
+import java.awt.Font
+import java.awt.event.KeyEvent
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+
+
+private const val DEFAULT_HIGHLIGHT_DURATION_SNEAK: Long = 300
+
+// By [Mikhail Levchenko](https://github.com/Mishkun)
+// Original repository with the plugin: https://github.com/Mishkun/ideavim-sneak
+internal class IdeaVimSneakExtension : VimExtension {
+  override fun getName(): String = "sneak"
+
+  override fun init() {
+    val highlightHandler = HighlightHandler()
+    mapToFunctionAndProvideKeys("s", SneakHandler(highlightHandler, Direction.FORWARD))
+    mapToFunctionAndProvideKeys("S", SneakHandler(highlightHandler, Direction.BACKWARD))
+
+    // workaround to support ; and , commands
+    mapToFunctionAndProvideKeys("f", SneakMemoryHandler("f"))
+    mapToFunctionAndProvideKeys("F", SneakMemoryHandler("F"))
+    mapToFunctionAndProvideKeys("t", SneakMemoryHandler("t"))
+    mapToFunctionAndProvideKeys("T", SneakMemoryHandler("T"))
+
+    mapToFunctionAndProvideKeys(";", SneakRepeatHandler(highlightHandler, RepeatDirection.IDENTICAL))
+    mapToFunctionAndProvideKeys(",", SneakRepeatHandler(highlightHandler, RepeatDirection.REVERSE))
+  }
+
+  private class SneakHandler(
+    private val highlightHandler: HighlightHandler,
+    private val direction: Direction,
+  ) : ExtensionHandler {
+    override fun execute(editor: VimEditor, context: ExecutionContext, operatorArguments: OperatorArguments) {
+      val charone = getChar(editor) ?: return
+      val chartwo = getChar(editor) ?: return
+      val range = Util.jumpTo(editor, charone, chartwo, direction)
+      range?.let { highlightHandler.highlightSneakRange(editor.ij, range) }
+      Util.lastSymbols = "${charone}${chartwo}"
+      Util.lastSDirection = direction
+    }
+
+    private fun getChar(editor: VimEditor): Char? {
+      val key = VimExtensionFacade.inputKeyStroke(editor.ij)
+      return when {
+        key.keyChar == KeyEvent.CHAR_UNDEFINED || key.keyCode == KeyEvent.VK_ESCAPE -> null
+        else -> key.keyChar
+      }
+    }
+  }
+
+  /**
+   * This class acts as proxy for normal find commands because we need to update [Util.lastSDirection]
+   */
+  private class SneakMemoryHandler(private val char: String) : VimExtensionHandler {
+    override fun execute(editor: Editor, context: DataContext) {
+      Util.lastSDirection = null
+      VimExtensionFacade.executeNormalWithoutMapping(injector.parser.parseKeys(char), editor)
+    }
+  }
+
+  private class SneakRepeatHandler(
+    private val highlightHandler: HighlightHandler,
+    private val direction: RepeatDirection,
+  ) : ExtensionHandler {
+    override fun execute(editor: VimEditor, context: ExecutionContext, operatorArguments: OperatorArguments) {
+      val lastSDirection = Util.lastSDirection
+      if (lastSDirection != null) {
+        val (charone, chartwo) = Util.lastSymbols.toList()
+        val jumpRange = Util.jumpTo(editor, charone, chartwo, direction.map(lastSDirection))
+        jumpRange?.let { highlightHandler.highlightSneakRange(editor.ij, jumpRange) }
+      } else {
+        VimExtensionFacade.executeNormalWithoutMapping(injector.parser.parseKeys(direction.symb), editor.ij)
+      }
+    }
+  }
+
+  private object Util {
+    var lastSDirection: Direction? = null
+    var lastSymbols: String = ""
+    fun jumpTo(editor: VimEditor, charone: Char, chartwo: Char, sneakDirection: Direction): TextRange? {
+      val caret = editor.primaryCaret()
+      val position = caret.offset.point
+      val chars = editor.text()
+      val foundPosition = sneakDirection.findBiChar(editor, chars, position, charone, chartwo)
+      if (foundPosition != null) {
+        editor.primaryCaret().moveToOffset(foundPosition)
+      }
+      editor.ij.scrollingModel.scrollToCaret(ScrollType.MAKE_VISIBLE)
+      return foundPosition?.let { TextRange(foundPosition, foundPosition + 2) }
+    }
+  }
+
+  private enum class Direction(val offset: Int) {
+    FORWARD(1) {
+      override fun findBiChar(
+        editor: VimEditor,
+        charSequence: CharSequence,
+        position: Int,
+        charone: Char,
+        chartwo: Char
+      ): Int? {
+        for (i in (position + offset) until charSequence.length - 1) {
+          if (matches(editor, charSequence, i, charone, chartwo)) {
+            return i
+          }
+        }
+        return null
+      }
+    },
+    BACKWARD(-1) {
+      override fun findBiChar(
+        editor: VimEditor,
+        charSequence: CharSequence,
+        position: Int,
+        charone: Char,
+        chartwo: Char
+      ): Int? {
+        for (i in (position + offset) downTo 0) {
+          if (matches(editor, charSequence, i, charone, chartwo)) {
+            return i
+          }
+        }
+        return null
+      }
+
+    };
+
+    abstract fun findBiChar(
+      editor: VimEditor,
+      charSequence: CharSequence,
+      position: Int,
+      charone: Char,
+      chartwo: Char,
+    ): Int?
+
+    fun matches(
+      editor: VimEditor,
+      charSequence: CharSequence,
+      charPosition: Int,
+      charOne: Char,
+      charTwo: Char,
+    ): Boolean {
+      var match = charSequence[charPosition].equals(charOne, ignoreCase = injector.options(editor).ignorecase) &&
+        charSequence[charPosition + 1].equals(charTwo, ignoreCase = injector.options(editor).ignorecase)
+
+      if (injector.options(editor).ignorecase && injector.options(editor).smartcase) {
+        if (charOne.isUpperCase() || charTwo.isUpperCase()) {
+          match = charSequence[charPosition].equals(charOne, ignoreCase = false) &&
+            charSequence[charPosition + 1].equals(charTwo, ignoreCase = false)
+        }
+      }
+      return match
+    }
+  }
+
+  private enum class RepeatDirection(val symb: String) {
+    IDENTICAL(";") {
+      override fun map(direction: Direction): Direction = direction
+    },
+    REVERSE(",") {
+      override fun map(direction: Direction): Direction = when (direction) {
+        Direction.FORWARD -> Direction.BACKWARD
+        Direction.BACKWARD -> Direction.FORWARD
+      }
+    };
+
+    abstract fun map(direction: Direction): Direction
+  }
+
+  private class HighlightHandler {
+    private var editor: Editor? = null
+    private val sneakHighlighters: MutableSet<RangeHighlighter> = mutableSetOf()
+
+    fun highlightSneakRange(editor: Editor, range: TextRange) {
+      clearAllSneakHighlighters()
+
+      this.editor = editor
+      val project = editor.project
+      if (project != null) {
+        Disposer.register(VimProjectService.getInstance(project)) {
+          this.editor = null
+          sneakHighlighters.clear()
+        }
+      }
+
+      if (range.isMultiple) {
+        for (i in 0 until range.size()) {
+          highlightSingleRange(editor, range.startOffsets[i]..range.endOffsets[i])
+        }
+      } else {
+        highlightSingleRange(editor, range.startOffset..range.endOffset)
+      }
+    }
+
+    fun clearAllSneakHighlighters() {
+      sneakHighlighters.forEach { highlighter ->
+        editor?.markupModel?.removeHighlighter(highlighter) ?: StrictMode.fail("Highlighters without an editor")
+      }
+
+      sneakHighlighters.clear()
+    }
+
+    private fun highlightSingleRange(editor: Editor, range: ClosedRange<Int>) {
+      val highlighter = editor.markupModel.addRangeHighlighter(
+        range.start,
+        range.endInclusive,
+        HighlighterLayer.SELECTION,
+        getHighlightTextAttributes(),
+        HighlighterTargetArea.EXACT_RANGE
+      )
+
+      sneakHighlighters.add(highlighter)
+
+      setClearHighlightRangeTimer(highlighter)
+    }
+
+    private fun setClearHighlightRangeTimer(highlighter: RangeHighlighter) {
+      Executors.newSingleThreadScheduledExecutor().schedule({
+        ApplicationManager.getApplication().invokeLater {
+          editor?.markupModel?.removeHighlighter(highlighter) ?: StrictMode.fail("Highlighters without an editor")
+        }
+      }, DEFAULT_HIGHLIGHT_DURATION_SNEAK, TimeUnit.MILLISECONDS)
+    }
+
+    private fun getHighlightTextAttributes() = TextAttributes(
+      null,
+      EditorColors.TEXT_SEARCH_RESULT_ATTRIBUTES.defaultAttributes.backgroundColor,
+      editor?.colorsScheme?.getColor(EditorColors.CARET_COLOR),
+      EffectType.SEARCH_MATCH,
+      Font.PLAIN
+    )
+  }
+}
+
+/**
+ * Map some <Plug>(keys) command to given handler
+ *  and create mapping to <Plug>(prefix)[keys]
+ */
+private fun VimExtension.mapToFunctionAndProvideKeys(keys: String, handler: ExtensionHandler) {
+  VimExtensionFacade.putExtensionHandlerMapping(
+    MappingMode.NXO,
+    injector.parser.parseKeys(command(keys)),
+    owner,
+    handler,
+    false
+  )
+  VimExtensionFacade.putKeyMapping(
+    MappingMode.NXO,
+    injector.parser.parseKeys(keys),
+    owner,
+    injector.parser.parseKeys(command(keys)),
+    true
+  )
+}
+
+private fun command(keys: String) = "<Plug>(sneak-$keys)"
diff --git a/src/main/resources/META-INF/includes/VimExtensions.xml b/src/main/resources/META-INF/includes/VimExtensions.xml
index 63d1e30d1..e2006e5a2 100644
--- a/src/main/resources/META-INF/includes/VimExtensions.xml
+++ b/src/main/resources/META-INF/includes/VimExtensions.xml
@@ -124,6 +124,14 @@
         <alias name="chrisbra/matchit"/>
       </aliases>
     </vimExtension>
+
+    <vimExtension implementation="com.maddyhome.idea.vim.extension.sneak.IdeaVimSneakExtension" name="sneak">
+      <aliases>
+        <alias name="https://github.com/justinmk/vim-sneak"/>
+        <alias name="justinmk/vim-sneak"/>
+        <alias name="vim-sneak"/>
+      </aliases>
+    </vimExtension>
   </extensions>
 
   <!--  IdeaVim extensions-->
diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt
index a3d8381d2..3b81e5aa7 100644
--- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt
+++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt
@@ -164,19 +164,20 @@ class SetCommandTest : VimTestCase() {
     assertCommandOutput("set all",
       """
         |--- Options ---
-        |noargtextobj        noideatracetime       scroll=0          nosurround
-        |  closenotebooks      ideawrite=all       scrolljump=1      notextobj-entire
-        |nocommentary        noignorecase          scrolloff=0       notextobj-indent
-        |nodigraph           noincsearch           selectmode=         timeout
-        |noexchange          nomatchit             shellcmdflag=-x     timeoutlen=1000
-        |nogdefault            maxmapdepth=20      shellxescape=@    notrackactionids
-        |nohighlightedyank     more                shellxquote={       undolevels=1000
-        |  history=50        nomultiple-cursors    showcmd             unifyjumps
-        |nohlsearch          noNERDTree            showmode            virtualedit=
-        |noideaglobalmode      nrformats=hex       sidescroll=0      novisualbell
-        |noideajoin          nonumber              sidescrolloff=0     visualdelay=100
-        |  ideamarks           octopushandler    nosmartcase           whichwrap=b,s
-        |  ideastrictmode    norelativenumber      startofline         wrapscan
+        |noargtextobj          ideawrite=all       scrolloff=0       notextobj-indent
+        |  closenotebooks    noignorecase          selectmode=         timeout
+        |nocommentary        noincsearch           shellcmdflag=-x     timeoutlen=1000
+        |nodigraph           nomatchit             shellxescape=@    notrackactionids
+        |noexchange            maxmapdepth=20      shellxquote={       undolevels=1000
+        |nogdefault            more                showcmd             unifyjumps
+        |nohighlightedyank   nomultiple-cursors    showmode            virtualedit=
+        |  history=50        noNERDTree            sidescroll=0      novisualbell
+        |nohlsearch            nrformats=hex       sidescrolloff=0     visualdelay=100
+        |noideaglobalmode    nonumber            nosmartcase           whichwrap=b,s
+        |noideajoin            octopushandler    nosneak               wrapscan
+        |  ideamarks         norelativenumber      startofline
+        |  ideastrictmode      scroll=0          nosurround
+        |noideatracetime       scrolljump=1      notextobj-entire
         |  clipboard=ideaput,autoselect,exclude:cons\|linux
         |  excommandannotation
         |  guicursor=n-v-c:block-Cursor/lCursor,ve:ver35-Cursor,o:hor50-Cursor,i-ci:ver25-Cursor/lCursor,r-cr:hor20-Cursor/lCursor,sm:block-Cursor-blinkwait175-blinkoff150-blinkon175
@@ -277,6 +278,7 @@ class SetCommandTest : VimTestCase() {
       |  sidescroll=0
       |  sidescrolloff=0
       |nosmartcase
+      |nosneak
       |  startofline
       |nosurround
       |notextobj-entire
diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt
index 4bd1a3108..cf18809a5 100644
--- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt
+++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt
@@ -350,19 +350,20 @@ class SetglobalCommandTest : VimTestCase() {
     setOsSpecificOptionsToSafeValues()
     assertCommandOutput("setglobal all", """
       |--- Global option values ---
-      |noargtextobj        noideatracetime       scroll=0          nosurround
-      |  closenotebooks      ideawrite=all       scrolljump=1      notextobj-entire
-      |nocommentary        noignorecase          scrolloff=0       notextobj-indent
-      |nodigraph           noincsearch           selectmode=         timeout
-      |noexchange          nomatchit             shellcmdflag=-x     timeoutlen=1000
-      |nogdefault            maxmapdepth=20      shellxescape=@    notrackactionids
-      |nohighlightedyank     more                shellxquote={       undolevels=1000
-      |  history=50        nomultiple-cursors    showcmd             unifyjumps
-      |nohlsearch          noNERDTree            showmode            virtualedit=
-      |noideaglobalmode      nrformats=hex       sidescroll=0      novisualbell
-      |noideajoin          nonumber              sidescrolloff=0     visualdelay=100
-      |  ideamarks           octopushandler    nosmartcase           whichwrap=b,s
-      |  ideastrictmode    norelativenumber      startofline         wrapscan
+      |noargtextobj          ideawrite=all       scrolloff=0       notextobj-indent
+      |  closenotebooks    noignorecase          selectmode=         timeout
+      |nocommentary        noincsearch           shellcmdflag=-x     timeoutlen=1000
+      |nodigraph           nomatchit             shellxescape=@    notrackactionids
+      |noexchange            maxmapdepth=20      shellxquote={       undolevels=1000
+      |nogdefault            more                showcmd             unifyjumps
+      |nohighlightedyank   nomultiple-cursors    showmode            virtualedit=
+      |  history=50        noNERDTree            sidescroll=0      novisualbell
+      |nohlsearch            nrformats=hex       sidescrolloff=0     visualdelay=100
+      |noideaglobalmode    nonumber            nosmartcase           whichwrap=b,s
+      |noideajoin            octopushandler    nosneak               wrapscan
+      |  ideamarks         norelativenumber      startofline
+      |  ideastrictmode      scroll=0          nosurround
+      |noideatracetime       scrolljump=1      notextobj-entire
       |  clipboard=ideaput,autoselect,exclude:cons\|linux
       |  excommandannotation
       |  guicursor=n-v-c:block-Cursor/lCursor,ve:ver35-Cursor,o:hor50-Cursor,i-ci:ver25-Cursor/lCursor,r-cr:hor20-Cursor/lCursor,sm:block-Cursor-blinkwait175-blinkoff150-blinkon175
@@ -459,6 +460,7 @@ class SetglobalCommandTest : VimTestCase() {
       |  sidescroll=0
       |  sidescrolloff=0
       |nosmartcase
+      |nosneak
       |  startofline
       |nosurround
       |notextobj-entire
diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt
index 8beecd4d4..9a24456c2 100644
--- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt
+++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt
@@ -382,19 +382,20 @@ class SetlocalCommandTest : VimTestCase() {
     setOsSpecificOptionsToSafeValues()
     assertCommandOutput("setlocal all", """
       |--- Local option values ---
-      |noargtextobj          ideastrictmode    norelativenumber      startofline
-      |  closenotebooks    noideatracetime       scroll=0          nosurround
-      |nocommentary          ideawrite=all       scrolljump=1      notextobj-entire
-      |nodigraph           noignorecase          scrolloff=-1      notextobj-indent
-      |noexchange          noincsearch           selectmode=         timeout
-      |nogdefault          nomatchit             shellcmdflag=-x     timeoutlen=1000
-      |nohighlightedyank     maxmapdepth=20      shellxescape=@    notrackactionids
-      |  history=50          more                shellxquote={       unifyjumps
-      |nohlsearch          nomultiple-cursors    showcmd             virtualedit=
-      |noideaglobalmode    noNERDTree            showmode          novisualbell
-      |--ideajoin            nrformats=hex       sidescroll=0        visualdelay=100
-      |  ideamarks         nonumber              sidescrolloff=-1    whichwrap=b,s
-      |  idearefactormode=   octopushandler    nosmartcase           wrapscan
+      |noargtextobj        noideatracetime       scrolljump=1      notextobj-entire
+      |  closenotebooks      ideawrite=all       scrolloff=-1      notextobj-indent
+      |nocommentary        noignorecase          selectmode=         timeout
+      |nodigraph           noincsearch           shellcmdflag=-x     timeoutlen=1000
+      |noexchange          nomatchit             shellxescape=@    notrackactionids
+      |nogdefault            maxmapdepth=20      shellxquote={       unifyjumps
+      |nohighlightedyank     more                showcmd             virtualedit=
+      |  history=50        nomultiple-cursors    showmode          novisualbell
+      |nohlsearch          noNERDTree            sidescroll=0        visualdelay=100
+      |noideaglobalmode      nrformats=hex       sidescrolloff=-1    whichwrap=b,s
+      |--ideajoin          nonumber            nosmartcase           wrapscan
+      |  ideamarks           octopushandler    nosneak
+      |  idearefactormode= norelativenumber      startofline
+      |  ideastrictmode      scroll=0          nosurround
       |  clipboard=ideaput,autoselect,exclude:cons\|linux
       |  excommandannotation
       |  guicursor=n-v-c:block-Cursor/lCursor,ve:ver35-Cursor,o:hor50-Cursor,i-ci:ver25-Cursor/lCursor,r-cr:hor20-Cursor/lCursor,sm:block-Cursor-blinkwait175-blinkoff150-blinkon175
@@ -497,6 +498,7 @@ class SetlocalCommandTest : VimTestCase() {
       |  sidescroll=0
       |  sidescrolloff=-1
       |nosmartcase
+      |nosneak
       |  startofline
       |nosurround
       |notextobj-entire
diff --git a/src/test/java/org/jetbrains/plugins/ideavim/extension/sneak/IdeaVimSneakTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/extension/sneak/IdeaVimSneakTest.kt
new file mode 100644
index 000000000..947f9e66b
--- /dev/null
+++ b/src/test/java/org/jetbrains/plugins/ideavim/extension/sneak/IdeaVimSneakTest.kt
@@ -0,0 +1,165 @@
+/*
+ * 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 org.jetbrains.plugins.ideavim.extension.sneak
+
+import com.maddyhome.idea.vim.state.mode.Mode
+import org.jetbrains.plugins.ideavim.VimTestCase
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.TestInfo
+
+class IdeaVimSneakTest : VimTestCase() {
+  @Throws(Exception::class)
+  override fun setUp(testInfo: TestInfo) {
+    super.setUp(testInfo)
+    enableExtensions("sneak")
+  }
+
+  @Test
+  fun testSneakForward() {
+    val before = "som${c}e text"
+    val after = "some te${c}xt"
+
+    doTest("sxt", before, after, Mode.NORMAL())
+  }
+
+  @Test
+  fun testSneakForwardVertical() {
+    val before = """som${c}e text
+        another line
+        third line"""
+    val after = """some text
+        another line
+        thi${c}rd line"""
+
+    doTest("srd", before, after, Mode.NORMAL())
+  }
+
+  @Test
+  fun testSneakForwardDoNotIgnoreCase() {
+    val before = "som${c}e teXt"
+    val after = "som${c}e teXt"
+
+    doTest("sxt", before, after, Mode.NORMAL())
+  }
+
+  @Test
+  fun testSneakForwardIgnoreCase() {
+    val before = "som${c}e teXt"
+    val after = "some te${c}Xt"
+
+    enableExtensions("ignorecase")
+
+    doTest("sxt", before, after, Mode.NORMAL())
+    doTest("sXt", before, after, Mode.NORMAL())
+    doTest("sXT", before, after, Mode.NORMAL())
+    doTest("sxT", before, after, Mode.NORMAL())
+  }
+
+  @Test
+  fun testSneakForwardSmartIgnoreCase() {
+    val before = "som${c}e teXt"
+    val after = "some te${c}Xt"
+
+    enableExtensions("ignorecase", "smartcase")
+
+    doTest("sxt", before, after, Mode.NORMAL())
+    doTest("sXt", before, after, Mode.NORMAL())
+    doTest("sXT", before, before, Mode.NORMAL())
+    doTest("sxT", before, before, Mode.NORMAL())
+  }
+
+  @Test
+  fun testSneakForwardAndFindAgain() {
+    val before = "som${c}e text text"
+    val after = "some text te${c}xt"
+
+    doTest("sxt;", before, after, Mode.NORMAL())
+  }
+
+  @Test
+  fun testSneakForwardAndFindReverseAgain() {
+    val before = "some tex${c}t text"
+    val after = "some ${c}text text"
+
+    doTest("ste,", before, after, Mode.NORMAL())
+  }
+
+  @Test
+  fun testSneakBackward() {
+    val before = "some tex${c}t"
+    val after = "so${c}me text"
+
+    doTest("Sme", before, after, Mode.NORMAL())
+  }
+
+  @Test
+  fun testSneakBackwardVertical() {
+    val before = """some text
+        another line
+        thi${c}rd line"""
+    val after = """so${c}me text
+        another line
+        third line"""
+
+    doTest("Sme", before, after, Mode.NORMAL())
+  }
+
+  @Test
+  fun testSneakBackwardAndFindAgain() {
+    // caret has to be before another character (space here)
+    val before = "some text text${c} "
+    val after = "some ${c}text text "
+
+    doTest("Ste;", before, after, Mode.NORMAL())
+  }
+
+  @Test
+  fun testSneakBackwardAndFindReverseAgain() {
+    val before = "some tex${c}t text"
+    val after = "some text ${c}text"
+
+    doTest("Ste,", before, after, Mode.NORMAL())
+  }
+
+  @Test
+  fun testEndOfFile() {
+    val before = """first line
+        some te${c}xt
+        another line
+        last line."""
+    val after = """first line
+        some text
+        another line
+        last lin${c}e."""
+
+    doTest("se.", before, after, Mode.NORMAL())
+  }
+
+  @Test
+  fun testStartOfFile() {
+    val before = """first line
+        some text
+        another${c} line
+        last line."""
+    val after = """${c}first line
+        some text
+        another line
+        last line."""
+
+    doTest("Sfi", before, after, Mode.NORMAL())
+  }
+
+  @Test
+  fun testEscapeFirstChar() {
+    val before = "so${c}me dwarf"
+    val after = "some ${c}dwarf"
+
+    doTest("sa<ESC>sdw", before, after, Mode.NORMAL())
+  }
+}