Create a Neovim plugin for a CLI tool

Mohan Raj - Feb 14 - - Dev Community

Introduction

Ever Wondered How IDE Plugins Work? 🤔 Let's Build One for Neovim! Ever used a cool plugin in your IDE and thought, "How did they DO that?" Or maybe you've admired how CLI tools seamlessly integrate into your workflow.

Let's build one for PHPStan.

What is PHStan?

PHPStan is an open source CLI tools that helps to find bugs in PHP projects without actually running it or writing test cases.

To learn more,
https://phpstan.org
https://github.com/phpstan/phpstan

Let's see how it works

It is all started from Composer. Lets install phpstan package using composer.

composer require --dev phpstan/phpstan
Enter fullscreen mode Exit fullscreen mode

To run the phpstan to figure out the potential bugs in our project.

./vendor/bin/phpstan analyse app
Enter fullscreen mode Exit fullscreen mode

While adding the phpstan composer package, phpstan binary file will be created in the ./vendor/bin folder. The second argument analyse is to tell phpstan to analyse. The third argument app is the folder that I want to analyse. We may not need to run it on entire project including vendor dependencies, so it is important to specify exact folders and/or files for a better performance.

Alright lets explore the Neovim Plugin for a moment.

How to create a Neovim Plugin?

Neovim provide a numerous lua apis to build a plugin. In fact the neovim source code has more lua code than c. Wait what is Lua? Lua is a powerful, efficient, lightweight, embeddable scripting language.

It is very simple to create a plugin in lua, all we have to do is to create a lua/plugin.lua file in the neovim's config folder. It would be something looks like this ~/.config/nvim/lua/phpstan.lua.

There are two ways to run a lua inside the neovim.
When the current buffer is a standalone lua file/plugin

:luafile %
Enter fullscreen mode Exit fullscreen mode

It can be executed as a lua package using,

:lua require'phpstan'
Enter fullscreen mode Exit fullscreen mode

How to show the diagnostic messages in Neovim?

Neovim has a diagnostic module and fluent api in lua. You can read the document here. As per the document,the flow would be,

  1. Create a namespace using nvim_create_namespace()
  2. Generate a table of Diagnostic
  3. Set the diagnostics to the buffer

Take a moment to look at the vim.Diagnostic interface here

Most of them are optional keys, this is going to be our diagnostic table

local diagnostic = {
  lnum = 1,
  col = 0,
  message = "",
  severity = vim.diagnostic.Severity.ERROR,
  code = "class.NotFound"
}
Enter fullscreen mode Exit fullscreen mode

Plenary

The idea is to execute the phpstan command from lua and parse the output. But by default phpstan stdout is a table and it is difficult to parse. But it supports multiple output formats. We are gonna choose json output because it is convenient for me to parse it and it has the more information than other formats. All we have to do is adding an additional option --error-format=json to the command.

In neovim we do not need to analyse the entire project but the current buffer. So the current buffers file name should be the last argument.

I choose the neovim’s https://github.com/nvim-lua/plenary.nvim. It helps us to interact with system commands in async way by providing nice and apis.

local Job = require'plenary.job'

Job:new({
  command = './vendor/bin/phpstan',
  args = {
      'analyse',
      '--error-format=json',
      vim.api.nvim_buf_get_name(vim.api.nvim_get_current_buf()),
  },
  cwd = vim.fn.getcwd(),
  on_exit = vim.schedule_wrap(function(j, return_val)
   -- Parse the output and generate the diagnostics
  end),
}):start()
Enter fullscreen mode Exit fullscreen mode

Lets parse the output

First we should check the return_val . It the exit code of the command that we execute. It the current file does not have any errors, the exit code must be 0 . So only the non zero exit code will have the output.

Lets eliminate the first

    if return_val == 0 then
      return
    end
Enter fullscreen mode Exit fullscreen mode

The object j has the result in a table. Each line would be a separate element in the table. Since the error format is json , all the json string will be in a single line. Vim has a built-in function vim.json.decode to decode the json string and convert it to a lua table.

    local result = j:result()
    local response = vim.json.decode(result[1])
Enter fullscreen mode Exit fullscreen mode

Wait, we first look in to the json output to parse it effectively.

{
    "totals": {
        "errors": 0,
        "file_errors": 3
    },
    "files": {
        "/home/praem90/projects/seolve-app/app/Http/Controllers/Twitter/TwitterAuthorizeController.php": {
            "errors": 1,
            "messages": [
                {
                    "message": "Instantiated class Abraham\\TwitterOAuth\\TwitterOAuth not found.",
                    "line": 19,
                    "ignorable": true,
                    "tip": "Learn more at https://phpstan.org/user-guide/discovering-symbols",
                    "identifier": "class.notFound"
                }
            ]
        },
        "/home/praem90/projects/seolve-app/app/Http/Controllers/Twitter/TwitterCallbackController.php": {
            "errors": 2,
            "messages": [
                {
                    "message": "Instantiated class Abraham\\TwitterOAuth\\TwitterOAuth not found.",
                    "line": 20,
                    "ignorable": true,
                    "tip": "Learn more at https://phpstan.org/user-guide/discovering-symbols",
                    "identifier": "class.notFound"
                },
                {
                    "message": "Instantiated class Abraham\\TwitterOAuth\\TwitterOAuth not found.",
                    "line": 31,
                    "ignorable": true,
                    "tip": "Learn more at https://phpstan.org/user-guide/discovering-symbols",
                    "identifier": "class.notFound"
                }
            ]
        },

    },
    "errors": []
}
Enter fullscreen mode Exit fullscreen mode

The output is pretty clean. The files element contains all the files. The each file contains an array of messages. Yes, you got it.

    local namespace = vim.api.nvim_create_namespace('phpstan') -- 1. Remember the first step of the vim.Diagnostic above

        -- 2. Generating the diagnostic messages table
    for file_path in pairs(response.files) do
        local bufnr = vim.fn.bufnr(file_path);
        local diagnostics = {}

        for i in pairs(response.files[file_path].messages) do
            local message = response.files[file_path].messages[i]
            local diagnostic = {
                bufnr = bufnr,
                lnum = message.line,
                col = 0, -- Since phpstan does not support columns, setting it to zero
                message = message.message,
                code = message.identifier,
            }

            if message.ignorable then
                diagnostic.severity = vim.diagnostic.severity.WARN
            else
                diagnostic.severity = vim.diagnostic.severity.ERROR
            end

            table.insert(diagnostics, diagnostic)
        end

    end
Enter fullscreen mode Exit fullscreen mode

Now we have the table of diagnostic messages. Lets just publish it to the buffer.

vim.diagnostic.set(namespace, bufnr, diagnostics) -- 3. Publishing
Enter fullscreen mode Exit fullscreen mode

Final notes

We cannot run this script manually. So we must bind it to a autocmd . For our use it must executed when we open up a file and modify it. Neovim provides a lot of events where we can add listeners to execute our custom scripts and that makes it so flexible and scalable.

The final code would be

local Job = require'plenary.job'

local M = {}

local namespace = vim.api.nvim_create_namespace('pream90.phpstan')

M.analyse =function ()
Job:new({
  command = './vendor/bin/phpstan',
  args = {
      'analyse',
      '--error-format=json',
      vim.api.nvim_buf_get_name(vim.api.nvim_get_current_buf()),
  },
  cwd = vim.fn.getcwd(),
  on_exit = vim.schedule_wrap(function(j, return_val)
    if return_val == 0 then
      return
    end
    local result = j:result()
    local response = vim.json.decode(result[1])
    for file_path in pairs(response.files) do
        local bufnr = vim.fn.bufnr(file_path);
        local diagnostics = {}

        for i in pairs(response.files[file_path].messages) do
            local message = response.files[file_path].messages[i]
            local diagnostic = {
                bufnr = bufnr,
                lnum = message.line,
                col = 0,
                message = message.message,
                source = message.tip,
                code = message.identifier,
                namespace = namespace
            }

            if message.ignorable then
                diagnostic.severity = vim.diagnostic.severity.WARN
            else
                diagnostic.severity = vim.diagnostic.severity.ERROR
            end

            table.insert(diagnostics, diagnostic)
        end

        vim.diagnostic.set(namespace, bufnr, diagnostics)
    end
  end),
}):start()

end

M.setup = function ()
    -- Registering auto command to the files that ends with the php extension
    vim.api.nvim_create_autocmd({"BufReadPre", "BufWritePost"}, {
        pattern = {"*.php"},
        callback = M.analyse
    })
end

return M

Enter fullscreen mode Exit fullscreen mode

We are returning a Lua table, we should call the setup in your neovim’s init file.

In my case its init.vim

lua require('phpstan').setup()
Enter fullscreen mode Exit fullscreen mode

Reopen your neovim and then you will see the diagnostic messages.

Thanks

. .