Published on

Create your own plugin for neovim.

Authors

Introduction

It started with a frustation. I couldn't find a single plugin that fitted my usecase. The plugin I had earlier couldn't do this one single thing. I searched and searched, found nothing. A thought came to me, I am a developer why don't I try to make this thing myself. I know right 🤓 (classic).

Now, I had this thought of how should my ideal plugin behave. It was easy since my requirements were not so vast. I started my journey and was stamping my keyboard for hours and It finally happened, my new and shiny plugin working just the way I wanted.

Today we will be creating a similar plugin for neovim written in lua. Don't panic if you don't know lua (I still don't).

What features will the plugin have ?

  • Detect file type (using the extension for sure)
  • Detect variables and inject variables into templates
  • Collect content present around the cursor or the mouse
  • Run the code content selected
  • Create a new buffer to display the output
  • Finally format the output to be displayed

Why are we creating these features ? The thing is I want to create a plugin that will execute the code I am hovering over (curl). I want multiple curl request to be present in a file and execute the one under my cursor. Also I want to make use of variables so that I don't have to repeat the url or the tokens. Finally I want to view the output in a separate window.

Now let's get started.

Basic structure of a plugin

Hmm, what does a plugin have ? Plugin are simple thing just some function that run when an event happens. Example: Run the code below my cursor when I click certain shortcuts.

For now lets create something that will print hello fams when a shortcut is pressed.

Initally, create a file as ~/.config/nvim/lua/customplugin.lua. The name (customplugin) is the name of our plugin It can be anything like myawesomeplugin, punisher as you wish. Now a plugin returns an object (in js terms) of methods and you expose these methods outside which can later be called as we wish.

-- inside ~/.config/nvim/lua/customplugin.lua

local M = {} 

function M.greet()
	print("hello fams")
end

return M

Now your plugin is ready. Easy right. You just have to import this in your init.lua and start using it. I want to bind this plugin to <leader>he. So when I press <leader>he it should show "hello fams" in the command line.


-- in your ~/.config/nvim/init.lua
vim.keymap.set('n', '<leader>he', ":lua require('customplugin').greet()<CR>", { desc = 'Execute my custom plugin' })

Now we are ready to test this. Reload you nvim and press <leader>he. You should see this in the command.

Always reload nvim after plugin changes to see its effect

 hello fam

Detect the file type

The real work starts now. Create a local function called GetFileName that will check the current focused buffer and get the file name and extract the extension using the regex. You can call this function from your greet to see the output.

-- in your customplugin.lua

local M = {}

local function GetFileName()
  local file_name = vim.api.nvim_buf_get_name(0)
  local ext = file_name:match '^.+%.(.+)$'
  return ext
end

function M.greet()
  extension = GetFileName()
  print(extension)
end

return M

When you run the previously set key bidings <leader>he you will get the following output. The file name for my case is first.txt. You can see the extension txt in the command.

 ext png

Detect and inject variables

Now we would like to define variables that can be later reused. We will define variables with @<varname> = <variable value> at the top of the file. And detech {{<varname>}} inside strings and replace it with the <variable value> during execution.

local function GetAllVariables()
  local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
  local variables = {}

  for _, line in ipairs(lines) do
    local key, value = line:match '^@([%w_]+)%s*=%s*(.+)$'
    if key and value then
      variables[key] = value
    else
      -- Stop parsing when the first non-variable line is encountered
      break
    end
  end

  return variables
end

local function ReplaceStringVariables(input)
  local variables = GetAllVariables()

  -- Replace patterns like {{username}} with the corresponding value
  return (input:gsub('{{(.-)}}', function(key)
    return variables[key] or error 'Variable ' .. key ' not found'
  end))
end

function M.greet()
  local out = GetAllVariables()

end

Now let's see an example of creating variables. There is no easy way of showing the replacement of variables in string right now. But we will see that in action later.

 variable

Collect content around cursor

This is the most important stuff. What do I mean by collect the content around the cursor ? I want to get all the text inside some delimeter.

for example.


###

this is the content

###

I should be able to get this is the content when I use keybinding present inside ### and ###. Now the delimeters can be anything. I will use ### as the delimeter. You can change this and make corresponding changes in the code. We will also replace the variables with there value when they are present inside the delimeters.

For this create a function Trim that will remove empty lines and a function GetContentBetweenHash to get the content.

local function Trim(s)
  return (s:gsub('^%s*(.-)%s*$', '%1'))
end

local function GetContentBetweenHashes()
  -- Get the current cursor position
  local cursor_pos = vim.api.nvim_win_get_cursor(0)
  local current_line = cursor_pos[1] - 1 -- 0-based index

  -- Get all lines in the file
  local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)

  -- Find the start of the first '###'
  local start_line = -1
  for i = current_line, 1, -1 do
    if lines[i]:match '^%s*###' then
      start_line = i
      break
    end
  end

  -- If no start block found, return an empty string
  if start_line == -1 then
    error 'Cannot find delimeter'
  end

  -- Find the end of the next '###'
  local end_line = -1
  for i = current_line, #lines do
    if lines[i]:match '^%s*###' then
      end_line = i
      break
    end
  end

  -- If no end block found, return an empty string
  if end_line == -1 then
    error 'Cannot find delimeter'
  end

  -- Collect the content between the '###' blocks
  local content = {}
  for i = start_line + 1, end_line - 1 do
    if not (Trim(lines[i]) == '') then
-- Replace the variable names with variable values
      table.insert(content, ReplaceStringVariables(lines[i]))
    end
  end

  return table.concat(content, '\n')
end

function M.greet()
  local output = GetContentBetweenHashes()
  print(output)
end

Well that is a lot of lines. What we essentially did for GetContentBetweenHashes is:

  • Find the current cursor position.
  • Traverse upwards from cursor position and find delimeter.
  • If upward delimter found record the delimeter line else error out.
  • Now traverse downward from current cursor position and find delimeter.
  • If downward delimeter found record the delimeter line else error out.
  • Now loop from upward delimeter line to downward delimeter line remove empty lines and replace the variables
  • Concat all the lines with newline and create a single string.

What is the result from this you say.

 replace

TaDaaa 😁. You can see the content between delimeters have been extracted and the variables have been replaced with their corresponding values.

Run the selected code

Now, we will try to write some bash inside the delimeter and get the output. We will use echo bash function that will just echo its parameters to the terminal.

Now create following functions.

local function RunSelectedCode()
  if GetFileName() == 'http' then
    local command = GetContentBetweenHashes()
    local commandOutput = vim.fn.system(command)
    print(commandOutput)
  end
end

function M.greet()
  RunSelectedCode()
end

We didn' use the GetFileName function until now. We will check for the file extension .http and only execute the code in terminal if it matches .http extension. I have created a .http file and pasted the follwing.

 echo

We can see the output of the echo command with replaced variable here.

Create a split buffer

Everything looks good for now. But I don't like to view my output in the small space at the bottom. How about we split the window in two and display the output there.

Create the follwing function to achieve split window.

function SplitWindowAndFocus(hashcontent)
  -- split window vertically
  vim.cmd 'vsplit'
  -- open a new empty buffer
  vim.cmd 'enew'

  -- get the buffer name
  local newBuff = vim.api.nvim_get_current_buf()

  -- set name for the buffer
  -- warning if a named buffer is already opened and you want to create 
  -- same buffer again it will through an error
  -- you can disable this but it is difficult to close an unamed buffer you have to suffix with !
  vim.api.nvim_buf_set_name(newBuff, 'custompluginbuffer')

  -- write to the buffer
  vim.api.nvim_buf_set_lines(newBuff, 0, -1, false, vim.split(hashcontent, '\n'))

  -- move the cursor to new split
  vim.cmd 'wincmd l'
end

local function RunSelectedCode()
  if GetFileName() == 'http' then
    local command = GetContentBetweenHashes()
    local commandOutput = vim.fn.system(command)

    -- display in new split buffer instead of the command
    SplitWindowAndFocus(commandOutput)
  end
end

Now when we test the above code. Tadaaaaa🤌🤌

 split

You can see the custom buffer name custompluginbuffer at the new window. And the code output in the window. Phewww we are almost there.

Formatting

For this section make sure curl and jq is installed in your system. The primary reason to create this plugin was for me to run curl commands on my API server. The plugin I used earlier used .http syntax and didn't allow multipart/form-data data to be sent. But I knew curl could easily do that. We will be using a public api for testing here.

We will be making a GET request to a public dog api using curl.

 ugly

Woow It looks like shit 💩. See that's why we need a formatter. The formatter we will be using is jq. You need to make following changes to your RunSelectedCode function.

local function RunSelectedCode()
  if GetFileName() == 'http' then
    local command = GetContentBetweenHashes()
    local commandOutput = vim.fn.system(command)
    -- format the output json
    local formattedOutput = vim.fn.system(string.format("echo '%s' | jq .", commandOutput))

    SplitWindowAndFocus(formattedOutput)
  end
end

After updating the function with formatter we will observer the following.

 formatted

See there is no need to follow the exact steps that I took here. I needed to view the json output in a prettier way so I had to use a formatter.

Conclusion

See it is not so hard to create a plugin to make your developmental workflow easier. You should be able to create plugins for your own needs now. You can now change the greet function that is used in keybinding according to your wish. I leave that as your homework.

I hope you enjoyed this article.

Keep searching and Keep learning. Happy Coding 💩💩

PS: follow me on twitter and github