Skip to content

Plugin API tutorial replace with register

IdeaVim Bot edited this page Dec 12, 2025 · 5 revisions

Tutorial: Creating an IdeaVim Plugin with the New API

⚠️ EXPERIMENTAL API WARNING

The Plugin API is currently in an experimental stage and is not yet recommended for production use.

  • The API is subject to breaking changes without notice
  • Features may be added, modified, or removed in future releases
  • Documentation may not fully reflect the current implementation
  • Use at your own risk for experimental purposes only

We welcome feedback and bug reports to help improve the API, but please be aware that stability is not guaranteed at this time.

This tutorial will guide you through the process of creating a plugin for IdeaVim using the new API. We'll implement a "Replace with Register" plugin that allows you to replace text with the contents of a register.

Table of Contents

Introduction

The "Replace with Register" plugin (link to the original Vim plugin) demonstrates several important concepts in IdeaVim plugin development:

  • Creating custom mappings for different Vim modes
  • Working with registers
  • Manipulating text in the editor
  • Handling different types of selections (character-wise, line-wise, block-wise)
  • Creating operator functions

This tutorial will walk you through each part of the implementation, explaining the concepts and techniques used.

Project Setup

  1. Clone the IdeaVim repo. (Todo: update)

Plugin Structure

IdeaVim plugins using the new API are typically structured as follows:

  1. An init function that sets up mappings and functionality
  2. Helper functions that implement specific features

Let's look at how to implement each part of our "Replace with Register" plugin.

Implementing the Plugin

Step 1: Create the init function

First, create a Kotlin file for your plugin:

@VimPlugin(name = "ReplaceWithRegister")
fun VimApi.init() {
  // We'll add mappings and functionality here
}

The init function has a responsibility to set up our plugin within the VimApi.

Step 2: Define Mappings

Now, let's add mappings to our plugin. We'll define three mappings:

  1. gr + motion: Replace the text covered by a motion with register contents
  2. grr: Replace the current line with register contents
  3. gr in visual mode: Replace the selected text with register contents

Add this code to the init function:

@VimPlugin(name = "ReplaceWithRegister")
fun VimApi.init() {
    mappings {
        // Step 1: Non-recursive <Plug> → action mappings
        nnoremap("<Plug>ReplaceWithRegisterOperator") {
            rewriteMotion()
        }
        nnoremap("<Plug>ReplaceWithRegisterLine") {
            rewriteLine()
        }
        vnoremap("<Plug>ReplaceWithRegisterVisual") {
            rewriteVisual()
        }

        // Step 2: Recursive key → <Plug> mappings
        nmap("gr", "<Plug>ReplaceWithRegisterOperator")
        nmap("grr", "<Plug>ReplaceWithRegisterLine")
        vmap("gr", "<Plug>ReplaceWithRegisterVisual")
    }

    exportOperatorFunction("ReplaceWithRegisterOperatorFunc") {
        operatorFunction()
    }
}

Let's break down what's happening:

  • The mappings block gives us access to the MappingScope
  • We use a 2-step mapping pattern:
    • Step 1: nnoremap/vnoremap create non-recursive mappings from <Plug> names to actions (lambdas)
    • Step 2: nmap/vmap create recursive mappings from user-facing keys (like "gr") to <Plug> names
  • This pattern allows users to override the key mappings in their .ideavimrc while keeping the underlying actions available
  • exportOperatorFunction registers a function that will be called when the operator is used with a motion

Step 3: Implement Core Functionality

Now, let's implement the functions we referenced in our mappings:

private fun VimApi.rewriteMotion() {
    setOperatorFunction("ReplaceWithRegisterOperatorFunc")
    normal("g@")
}

private suspend fun VimApi.rewriteLine() {
    val count1 = getVariable<Int>("v:count1") ?: 1
    val job: Job
    editor {
        job = change {
            forEachCaret {
              val endOffset = getLineEndOffset(line.number + count1 - 1, true)
              val lineStartOffset = line.start
              val registerData = prepareRegisterData() ?: return@forEachCaret
              replaceText(lineStartOffset, endOffset, registerData.first)
              updateCaret(offset = lineStartOffset)
            }
        }
    }
    job.join()
}

private suspend fun VimApi.rewriteVisual() {
    val job: Job
    editor {
        job = change {
            forEachCaret {
                val selectionRange = selection
                val registerData = prepareRegisterData() ?: return@forEachCaret
                replaceTextAndUpdateCaret(this@rewriteVisual, selectionRange, registerData)
            }
        }
    }
    job.join()
    mode = Mode.NORMAL()
}

private suspend fun VimApi.operatorFunction(): Boolean {
    fun CaretTransaction.getSelection(): Range? {
        return when (this@operatorFunction.mode) {
            is Mode.NORMAL -> changeMarks
            is Mode.VISUAL -> selection
            else -> null
        }
    }

    val job: Job
    editor {
        job = change {
            forEachCaret {
                val selectionRange = getSelection() ?: return@forEachCaret
                val registerData = prepareRegisterData() ?: return@forEachCaret
                replaceTextAndUpdateCaret(this@operatorFunction, selectionRange, registerData)
            }
        }
    }
    job.join()
    return true
}

Let's examine each function:

  • rewriteMotion(): Sets up an operator function and triggers it with g@
  • rewriteLine(): Replaces one or more lines with register contents
  • rewriteVisual(): Replaces the visual selection with register contents
  • operatorFunction(): Implements the operator function

Notice the use of scopes:

  • editor { } gives us access to the editor
  • change { } creates a transaction for modifying text
  • forEachCaret { } iterates over all carets (useful for multi-cursor editing)

Step 4: Handle Different Selection Types

Now, let's implement the helper functions that prepare register data and handle different types of selections:

private suspend fun CaretTransaction.prepareRegisterData(): Pair<String, TextType>? {
    val lastRegisterName: Char = lastSelectedReg
    var registerText: String = getReg(lastRegisterName) ?: return null
    var registerType: TextType = getRegType(lastRegisterName) ?: return null

    if (registerType.isLine && registerText.endsWith("\n")) {
        registerText = registerText.removeSuffix("\n")
        registerType = TextType.CHARACTER_WISE
    }

    return registerText to registerType
}

private suspend fun CaretTransaction.replaceTextAndUpdateCaret(
    vimApi: VimApi,
    selectionRange: Range,
    registerData: Pair<String, TextType>,
) {
    val (text, registerType) = registerData

    if (registerType == TextType.BLOCK_WISE) {
        val lines = text.lines()

        if (selectionRange is Range.Simple) {
            val startOffset = selectionRange.start
            val endOffset = selectionRange.end
            val startLine = getLine(startOffset)
            val diff = startOffset - startLine.start

            lines.forEachIndexed { index, lineText ->
                val offset = getLineStartOffset(startLine.number + index) + diff
                if (index == 0) {
                    replaceText(offset, endOffset, lineText)
                } else {
                    insertText(offset, lineText)
                }
            }

            updateCaret(offset = startOffset)
        } else if (selectionRange is Range.Block) {
            replaceTextBlockwise(selectionRange, lines)
        }
    } else {
        if (selectionRange is Range.Simple) {
            val textLength = this.text.length
            if (textLength == 0) {
              insertText(0, text)
            } else {
              replaceText(selectionRange.start, selectionRange.end, text)
            }
        } else if (selectionRange is Range.Block) {
            replaceTextBlockwise(selectionRange, text)

            vimApi.mode = Mode.NORMAL()
            updateCaret(offset = selectionRange.start)
        }
    }
}

These functions handle:

  1. prepareRegisterData(): Gets the content and type of the last used register
  2. replaceTextAndUpdateCaret(): Handles the replacement logic for different types of selections and register contents

Testing Your Plugin

For the "Replace with Register" plugin, you can test it by:

  1. Yanking some text with y
  2. Moving to different text and using gr followed by a motion
  3. Selecting text in visual mode and using gr
  4. Using grr to replace a whole line

For more information, check out the API Reference and the Quick Start Guide.

Clone this wiki locally