DEV Community

Cover image for How to Create Vim Text-Objects in Lua
Matthieu Cneude
Matthieu Cneude

Posted on • Originally published at thevaluable.dev

How to Create Vim Text-Objects in Lua

"You don't get it. Vim is like a language! You'll speak Vim when you write! When you go to the market! You'll speak Vim with your cat! When you think, it will be the Word of Vim™ in your head! You'll see! It will change your life!"

This is the kind of argument any Vim hippy would sing to the poor heretics, trying to convert them to The Eternal Editor. A hippy like me, who's now writing an article about one of the main component of this "language", the text-object. Repeat after me: glory to the text-object!

It's powerful to use them, that's true; but we can do better. We could create new text-objects, like a god creating life! We could shape the Chaos to bring order in the galaxy!

The Greek god Hephaestus created the fire in his legendary forge. Like him, we also need some tools to create our own text-objects. What on Earth can we use? Vimscript?

With Neovim, we can use Lua to customize our favorite editor; we can also compile Vim with Lua support. To me, one of the best way to learn how to customize Vim is to take already written functions in Vimscript and transform them in Lua.

The benefits:

  1. If you don't know Vimscript, analyzing the code to transform it in Lua will show you useful functions you can use for more customization. It's essential for harnessing Vim's power.
  2. If you don't know Lua, you can learn it this way, too. It's a great programming language, used by many other tools, like the fantastic Pandoc for example.
  3. You'll get a deeper understanding how Vim works, allowing your to improve your workflow.

So let's taste the power of Vimscript functions combined with Lua constructs, by implementing useful text-objects. More specificaly, we'll see in this article:

  • The general principles governing text-objects.
  • How to create a text-object representing a line.
  • How to create text-objects delimited by a pair of characters, like two commas.
  • How to create a very useful - and quite universal - text-object matching some level of indentation.

The text-objects we'll create are inspired by other authors from The Great Internet™. You'll find the sources of this inspiration at the end of the article.

I assume here that you're somehow proficient with Vim. If you don't understand what's happening in this article, you can look at my series of articles to learn Vim.

If it's the first time you write some Lua, I would encourage you to customize things further by modifying the examples we'll see in this article. To use the correct syntax, you should keep a quick reference on side, like this one.

Are you ready to dive into Vimscript, Lua, and text-objects? I'm sure you are! Let's go!

What's a Text-Object?

Before jumping into Vim to create our new delightful text-objects, let's first do what any developer should do when she needs to solve a problem: thinking. Let's decompose what a text-object is:

  1. Text-objects are NORMAL mode keystrokes using two keys; the first one can only be an "a" (for "around") or an "i" (for "inside").
  2. A text-object represents a specific string of characters, with a beginning and an end.
  3. It can only be used after an operator, or after switching to VISUAL mode character-wise (using "v" in NORMAL mode).
  4. The operator will act on the text-object, VISUAL mode will select the text-object itself.
  5. To apply an operator on some text-objects, the cursor doesn't have to be on the text-object itself. In that case, the operator will act on the next text-object of the line.

If you never heard of the rule 5, it can change your life, and bring you glory and fortune in a couple of days. Nothing less! If you type di( in NORMAL mode, and your cursor is before a pair of parentheses, you'll move inside them. The operator d will then delete everything inside. Neat!

As I was saying just above, a text-object can begin with an "a" or an "i". I understand text-objects beginning with "a" as "around", even if Vim's help will refer to them as the indefinite article a, like delete a word. I prefer "around" because this kind of text-objects often include some characters around the "inside" text-object. It's a good mnemonic to remember the difference between the two.

Motions are looking a lot like text-objects, but they're not the same. Using an operator before a motion will perform an action from the cursor position to the end result of the motion. A text-object doesn't necessarily start at the cursor position; that's the biggest difference.

For example, daw will delete around a word, whatever the letter of the word your cursor is on. Using a motion, dw will delete from the cursor position to the next word.

Now that we explored the problem space, let's enter the solution space. To create a text-object with a start and an end, we could simply:

  1. Select the characters we want.
  2. Apply whatever operator on the selection.

That's great, because Vim has some mapping allowing us to do that easily.

Vim Help

  • :help text-object
  • :help motion

The Operator-Pending Mapping

Basics

When we hit an operator in NORMAL mode (like d, y, or c, for example), we switch silently to the OPERATOR-PENDING mode. It's in this mode that we'll input our motion (or text-object) we want to operate on. Then, we switch back to NORMAL mode, automagically.

To define new text-objects (or motions) which can be used in OPERATOR-PENDING mode, you can use an operator-pending mapping: omap or, if you don't want a recursive mapping, onoremap. Thanks to them, we only need to worry about selecting the characters included in our text-objects; we don't care about the operators themselves.

The Line: Our New Text-Object

Let's now create the first text-object of this article: a line.

  • The text-object al ("around the line) will delete everything, including the possible indentations at the beginning.
  • The text-object il (inside the line) won't delete the indentation.

Here's a first mapping for il:

onoremap <silent> il :<c-u>normal! $v^<cr>
Enter fullscreen mode Exit fullscreen mode

In case you're not sure what that means, here are some explanations:

  • <silent> - Don't echo the Ex-command executed in the command-line window.
  • <c-u> - Delete everything already written in COMMAND-LINE mode (like the selection markers '<,'> for example).
  • normal! - This Ex-command allow executing NORMAL mode keystrokes.
  • $v^ - Go to the end of the line, switch to VISUAL mode, and select till the beginning of the line (without the eventual white spaces).

For example, if you hit yil or dil, your operators yank and delete will operate on our new text-object. If we only want to select it, we need another mapping for VISUAL mode:

xnoremap <silent> il :<c-u>normal! $v^<cr>
Enter fullscreen mode Exit fullscreen mode

You can now hit vil and admire the potential of your infinite skills.

We have now the "inside" version of our text-object; what about the "around" one?

xnoremap <silent> al :<c-u>normal! $v0<cr>
onoremap <silent> al :<c-u>normal! $v0<cr>
Enter fullscreen mode Exit fullscreen mode

These mappings follow the same principles, except that we select everything this time, including the eventual whitespaces at the beginning of the line. We could also use V instead of $v0.

Vim Help

  • :help omap-info

The Missing Text-Objects

Our new text-objects are great, but we can do better. This time, we'll create a whole series, delimited by different pair of characters.

Between Two Characters

It's easy to delete a pair of parentheses: a(, a), or even ab are there for you. As always, there are some "inside" variants, too. But what about deleting everything between two dots? Between two hyphens? Between two commas?

What about creating text-objects delimited by these characters: ,,.,;,:,+,-,=,~,_,*,#,/,|,\,&,$,??

To solve this problem, we could look at a simple example:

Hello, how are you, you?
Enter fullscreen mode Exit fullscreen mode

If we want to change the characters between the two commas, we could:

  1. Move the cursor to the first comma.
  2. Select everything till the second comma.
  3. Whether including the commas in the selection or not will be the difference between the "around" and the "inside" text-object.

This scenario requires our cursor to be between the two commas, at least for now. We'll improve this soon.

To move our cursor to the first or second comma, we can use f, F, t, and T in NORMAL mode. Here's a possible solution:

xnoremap <silent> i, :<c-u>normal! T,vt,<cr>
onoremap <silent> i, :<c-u>normal! T,vt,<cr>
xnoremap <silent> a, :<c-u>normal! F,ft,<cr>
onoremap <silent> a, :<c-u>normal! F,ft,<cr>
Enter fullscreen mode Exit fullscreen mode

We could repeat these mappings for every character we want to include. But it would be quite difficult to read and maintain. Taking some inspiration from a Zsh snippet I've already covered in this article, we could:

  1. Loop through all the different characters.
  2. Loop through all the different modes we want to map (OPERATOR-PENDING mode and VISUAL mode).
  3. Create the different mappings for each iteration.

Here's a possible implementation in Vimscript:

let s:chars = [ '_', '.', ':', ',', ';', '<bar>', '/', '<bslash>', '*', '+', '%', '`', '?' ]
for char in s:chars
    for mode in [ 'xnoremap', 'onoremap' ]
        execute printf('%s <silent> i%s :<C-u>normal! T%svt%s<CR>', mode, char, char, char)
        execute printf('%s <silent> a%s :<C-u>normal! F%svf%s<CR>', mode, char, char, char)
    endfor
endfor
Enter fullscreen mode Exit fullscreen mode

I like to use Vimscript for simple configurations, but as soon as I need to reach for loops or conditionals (I hate the equality-operators-mess in Vimscript), I switch to Lua. If you use Neovim, or if you have Vim compiled with Lua, you can use the following:

function basic_text_objects()
    local chars = { '_', '.', ':', ',', ';', '|', '/', '\\', '*', '+', '%', '`', '?' }
    for _,char in ipairs(chars) do
        for _,mode in ipairs({ 'x', 'o' }) do
            vim.api.nvim_set_keymap(mode, "i" .. char, string.format(':<C-u>normal! T%svt%s<CR>', char, char), { noremap = true, silent = true })
            vim.api.nvim_set_keymap(mode, "a" .. char, string.format(':<C-u>normal! F%svf%s<CR>', char, char), { noremap = true, silent = true })
        end
    end
end

return {
    basic_text_objects = basic_text_objects
}
Enter fullscreen mode Exit fullscreen mode

I would advise you to keep your Lua scrips in a lua/<namespace> folder; <namespace> could be your username, or anything else you'd like. For example, my scripts are in $XDG_CONFIG_HOME/nvim/lua/hypnos.

Then, you can call directly your new functions in your vimrc. If it's a Vimscript file, you'll need the prefix lua as follows:

lua require('<namespace>/text_objects').basic_text_objects()
Enter fullscreen mode Exit fullscreen mode

To The Next Text-Object

Right now, we need our cursor between the two characters to act on our new text-objects. It would also be nice to be able to operate on the first pair of these characters when our cursor is before them. As we saw above in this article, it's already what we can do with parenthesis or quotes.

Let's consider the following example:

She felt, suddenly, deeply in love with Vim.
Enter fullscreen mode Exit fullscreen mode

We could try to end up on the first comma of the pair whether the cursor is before them or between them. From there, we would select everything till the second comma. Here's a possible mapping:

onoremap <silent> i, :<C-u>silent! normal! f,F,lvt,<cr>
Enter fullscreen mode Exit fullscreen mode

Let's imagine that the cursor is on the first d of suddenly, between the commas.

  1. f, - Move to the second comma.
  2. F, - Move to the first comma.
  3. l - Move one character to the right (on the space after the comma).
  4. vt, - Visually select everything till the next comma.

Now, let's imagine that our cursor is on the S of She, before the commas.

  1. f, - Move to the first comma.
  2. F, - There is no comma before the cursor to be found, so the Ex-command normal! fails.
  3. l - Move one character to the right (on the space after the comma).
  4. vt, - Visually select everything till the next comma

Note that the Ex-command normal! fails at the second step. By default, it would stop there, not executing the steps 3 and 4. That's why we add the Ex-command :silent! here; it will force normal! to execute till the end.

For the "around" text-object, we can follow the same principles. The only difference: we also select the two commas.

onoremap a, :<C-u>silent! normal! f,F,vf,<cr>
Enter fullscreen mode Exit fullscreen mode

Here's the final result, in Lua:

function basic_text_objects()
    local chars = { '_', '.', ':', ',', ';', '|', '/', '\\', '*', '+', '%', '`', '?' }
    for idx,char in ipairs(chars) do
        for idx,mode in ipairs({ 'x', 'o' }) do
            vim.api.nvim_set_keymap(mode, "i" .. char, string.format(':<C-u>silent! normal! f%sF%slvt%s<CR>', char, char), { noremap = true, silent = true })
            vim.api.nvim_set_keymap(mode, "a" .. char, string.format(':<C-u>silent! normal! f%sF%svf%s<CR>', char, char), { noremap = true, silent = true })
        end
    end
end
Enter fullscreen mode Exit fullscreen mode

Keep in mind that it's a naive approach. First, making the normal! command fails feels a bit like a hack. Additionally, it's quite difficult to understand. That said, sometimes a hack is good enough to answer our needs and move forward. I wouldn't use that in a plugin which could be used by others, but for my own use it's good enough.

Each time we've created our text-objects, we first stated the problem, and then tried to find a way toward a solution. That's a good iterative approach, but it also means that our text-objects might not cover all the possible scenario. We can still improve things afterward, if we see that our text-objects don't behave as expected in specific situations.

This is different from installing a plugin: you often have no idea how it works, and you can't modify them iteratively to answer your specific needs.

Text-Objects Based On Indentations

Let's create one more text-object, more complex this time. It can be useful for any developer.

The Basics

If you look at the text-objects already available in vanilla Vim, their boundaries are based on specific characters. There are other important elements you'll find in most codebases which can be used for a text-object: indentations.

Here's how to select our new text-objects:

  1. Get the starting indentation of the current line. This will be the indentation of reference we'll compare every other line to.
  2. Move up line by line, as long as the indentation is equal or higher to the starting indentation.
  3. Stop when the next line doesn't fulfill the rule 2 anymore, and begin VISUAL mode line-wise.
  4. Move down till the indentation is equal or higher to the starting indentation.

It means that our text-objects include all the lines having the same indentation as the one you start with, or higher. Since the behavior is more complex than other text-objects we've created, let's implement a function we'll call in our mappings:

function! IndentTextObject()
  let startindent = indent(line('.'))

  " Move up till we are at the top of the buffer
  " or till the indentation is less than the starting one
  let prevline = line('.') - 1
  while prevline > 0 && indent(prevline) >= startindent
    -
    let prevline = line('.') - 1
  endwhile

  " Begin linewise-visual selection
  normal! 0V

  " Move down till we are at the bottom of the buffer
  " or till the indentation is less than the starting one
  let nextline = line('.') + 1
  let lastline = line('$')
  while nextline <= lastline && indent(nextline) >= startindent
    +
    let nextline = line('.') + 1
  endwhile
endfunction

onoremap <silent>ai :<C-U>call IndentTextObject()<CR>
onoremap <silent>ii :<C-U>call IndentTextObject()<CR>
xnoremap <silent>ai :<C-U>call IndentTextObject()<CR>
xnoremap <silent>ii :<C-U>call IndentTextObject()<CR>
Enter fullscreen mode Exit fullscreen mode

Again, I always prefer using Lua when things get slightly complex:

function select_indent()
    local start_indent = vim.fn.indent(vim.fn.line('.'))
    local prev_line = vim.fn.line('.') - 1

    while prev_line > 0 and vim.fn.indent(prev_line) >= start_indent do
        vim.cmd('-')
        prev_line = vim.fn.line('.') - 1
    end

    vim.cmd('normal! 0V')

    local next_line = vim.fn.line('.') + 1
    local last_line = vim.fn.line('$')
    while next_line <= last_line and vim.fn.indent(next_line) >= start_indent do
        vim.cmd('+')
        next_line = vim.fn.line('.') + 1
    end
end

function indent_text_objects()
    for _,mode in ipairs({ 'x', 'o' }) do
        vim.api.nvim_set_keymap(mode, 'ii', ':<c-u>lua select_indent()<cr>', { noremap = true, silent = true })
        vim.api.nvim_set_keymap(mode, 'ai', ':<c-u>lua select_indent()<cr>', { noremap = true, silent = true })
    end
end

return {
    indent_text_objects = indent_text_objects,
}
Enter fullscreen mode Exit fullscreen mode

The core of the functions are the two while loops. They will:

  1. Move the cursor up till the previous line has less indentation than the starting one.
  2. Begin VISUAL mode line-wise (with normal! 0V) and go down line by line, selecting the ones at the same level of indentation or higher.

Note that we use the Ex-command - and + to move up and down. Yes, they're Ex-command: try :+ for example. You could also use normal! k or normal! <up>.

Improving the Implementation

Our little function works great, but it shows quickly its limits. Perhaps the most obvious: the "inside" and "around" text-objects have the exact same behavior. Horror and damnation!

Let's say that the "around" the text-object should select two more lines, the first lines having fewer indentations when we move up and down.

To illustrate, consider the following code. The symbol "┃" represents the cursor:

-- Comment
function super_function()
   if true then
       print('youpi')
       print('wuhu')
    end
end
-- Comment
Enter fullscreen mode Exit fullscreen mode

Hitting dii would delete the whole if block but let everything else untouched. Hitting dai would delete everything, except the two comments.

From there, we have two solutions:

  1. Create two different functions: one for the "around" text-object, one for the "inside".
  2. Pass a boolean flag to use the "around" or "inside" text-object in the same function.

If we go with the first solution, we would have almost identical functions; it's likely we would need to change both each time we want to improve our text-objects. So let's go with the second solution. That said, if there are too many conditionals creeping in because there are more and more differences between the two text-objects, I would definitely split the function.

Here's a possible implementation:

function select_indent(around)
    local start_indent = vim.fn.indent(vim.fn.line('.'))
    local prev_line = vim.fn.line('.') - 1

    while prev_line > 0 and vim.fn.indent(prev_line) >= start_indent do
        vim.cmd('-')
        prev_line = vim.fn.line('.') - 1
    end
    if around then
        vim.cmd('-')
    end

    vim.cmd('normal! 0V')

    local next_line = vim.fn.line('.') + 1
    local last_line = vim.fn.line('$')
    while next_line <= last_line and vim.fn.indent(next_line) >= start_indent do
        vim.cmd('+')
        next_line = vim.fn.line('.') + 1
    end
    if around then
        vim.cmd('+')
    end
end

function indent_text_objects()
    for _,mode in ipairs({ 'x', 'o' }) do
        vim.api.nvim_set_keymap(mode, 'ii', ':<c-u>lua select_indent()<cr>', { noremap = true, silent = true })
        vim.api.nvim_set_keymap(mode, 'ai', ':<c-u>lua select_indent(true)<cr>', { noremap = true, silent = true })
    end
end

return {
    indent_text_objects = indent_text_objects,
}
Enter fullscreen mode Exit fullscreen mode

Next, we could ignore the blank lines. Right now, we don't really care about them; but they will inevitably stop our moves up and down because their level of indentation will always be less than the starting one. That is, if your cursor wasn't on a line without indentation from the start, in which case you would select the entire buffer.

We can define a blank line as a line only containing white space (spaces, tabs, and newlines). To find out if the previous or next line is indeed blank, we could use a regex:

local blank_line_pattern = '^%s*$'
Enter fullscreen mode Exit fullscreen mode

Regexes in Lua are a bit odd: where you would have \s for representing white spaces in many other regex engines, you have %s in Lua.

We now need to verify at each iteration if the next line is blank. We could create a small function for that:

local prev_blank_line = function(line) return string.match(vim.fn.getline(line), blank_line_pattern) end
Enter fullscreen mode Exit fullscreen mode

We also need to modify the conditions for our two while loops. For example:

while prev_line > 0 and (prev_blank_line(prev_line) or vim.fn.indent(prev_line) >= start_indent) do
Enter fullscreen mode Exit fullscreen mode

A last detail which might suits you. When your cursor is on a blank line while summoning your text-object with your agile fingers, you could ignore the keystroke. If you find the idea appealing, you could add the following before the first while loop:

if string.match(vim.fn.getline('.'), blank_line_pattern) then
    return
end
Enter fullscreen mode Exit fullscreen mode

We've now patched the most obvious defects of our text-object. But we can do better! What about being able to give a count to select lower levels of indentation?

For example, dai would work as it does now, but d2ai would select a lower level of indentation, and d3ai would select even more.

To do so, we simply need to increase our starting indentation depending on the count given. Right now, we only know what's the level of indentation of the current line with our variable start_indent, but we don't know the amount of indentations for each level.

To get this information, we can look at the value of the option shiftwidth.

Here's the implementation:

local start_indent = vim.fn.indent(vim.fn.line('.'))
if vim.v.count > 0 then
    start_indent = start_indent - vim.o.shiftwidth * (vim.v.count - 1)
    if start_indent < 0 then
        start_indent = 0
    end
end
Enter fullscreen mode Exit fullscreen mode

You might wonder about the calculation:

start_indent = start_indent - vim.o.shiftwidth * (vim.v.count - 1)
Enter fullscreen mode Exit fullscreen mode

First, vim.v.count is the count given to the text-object. In Vimscript, it would be v:count.

Regarding the calculation itself, we need to multiply the count given by the amount of indentations per level (the shiftwidth option), and subtract it to the indentations of the current line. It gives us the amount of indentations one level above.

We decide that a count of 1 shouldn't change anything (dai is the same as d1ai), that's why we subtract 1 from the count. We also verify if our starting indentation is lower than 0, which could happen if we enter a count on a line without any indentation. In that case, We force the starting indentation to 0.

Here's the final function:

function select_indent(around)
    local start_indent = vim.fn.indent(vim.fn.line('.'))
    local blank_line_pattern = '^%s*$'

    if string.match(vim.fn.getline('.'), blank_line_pattern) then
        return
    end

    if vim.v.count > 0 then
        start_indent = start_indent - vim.o.shiftwidth * (vim.v.count - 1)
        if start_indent < 0 then
            start_indent = 0
        end
    end

    local prev_line = vim.fn.line('.') - 1
    local prev_blank_line = function(line) return string.match(vim.fn.getline(line), blank_line_pattern) end
    while prev_line > 0 and (prev_blank_line(prev_line) or vim.fn.indent(prev_line) >= start_indent) do
        vim.cmd('-')
        prev_line = vim.fn.line('.') - 1
    end
    if around then
        vim.cmd('-')
    end

    vim.cmd('normal! 0V')

    local next_line = vim.fn.line('.') + 1
    local next_blank_line = function(line) return string.match(vim.fn.getline(line), blank_line_pattern) end
    local last_line = vim.fn.line('$')
    while next_line <= last_line and (next_blank_line(next_line) or vim.fn.indent(next_line) >= start_indent) do
        vim.cmd('+')
        next_line = vim.fn.line('.') + 1
    end
    if around then
        vim.cmd('+')
    end
end
Enter fullscreen mode Exit fullscreen mode

That's it! We've created a wonderful text-object with the power of our linked spirits. It's with joy and proud that I dub you Godly Blacksmith of the Text-Object©.

Mapping a Lua Function

Till now, we've used some Vimscript strings to map our Lua functions to our new text-objects. Neovim 0.7, which came out a couple of days before this article was published, allows us to directly map Lua functions without using any Vimscript.

You can try to look at :help vim.keymap.set for example to improve our code even further.

The Power of the Text-Object

As always, coming up with new ideas of text-objects depend on your needs. I always prefer solving the pain points I notice when writing in Vim, instead of trying to come up with ideas of improvements out of nothing. For example, I always wanted to have a text-object to manipulate functions; at the same time, I wanted something which could work with most programming languages. Having a text-object based on indentation levels is the best compromise to me.

So, what did we see in this article?

  • When you hit an operator in NORMAL mode, you switch to OPERATOR-PENDING mode. From there, you can give a motion or a text-object to operate on what your want.
  • A text-object is only a set of characters which can be acted upon, using operators.
  • It's easy to build basic text-objects thanks to the operator-pending mappings, omap and onoremap.
  • It's easier to write a function when you need more complex text-objects. You can then rely on the rich set of Vimscript functions to move your cursor and select what you want.
  • Vimscript is a language with many weird design decisions. I prefer using Lua as soon as my data flow goes on multiple branches.

Do you want more articles where we use Vimscript and Lua, to improve your customization power in Vim? Don't hesitate to give your thoughts, feedback, and improvements in the comment section. Like, share, and love.

References


Learning to Play Vim: A Fun Guide to Learn the Best Editor

Learning to Play Vim

I began to write a very ambitious guide to learn Vim from the ground up. Thanks to great feedback from my readers, I'll be able to address the problems many beginners complain about when learning Vim. For example:

  • How to navigate in multiple files and projects in Vim?
  • How to debug in Vim?
  • How to search, find, and replace?

This guide will explain the most useful vanilla functionalities as well as some powerful plugins which will enrich your experience.

Help me make an impact in the Vim world! You can subscribe to the newsletter and tell me everything you want to see in the book. Early bird discount guarantees!

I reply to every email personally, so don't hesitate to ask as many questions as you want. It's always a pleasure to help.

Last but not least: I've also written a book about building your own Mouseless Development Environment, so if you're interested by that too, click on this shiny link.


Top comments (0)