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
To run the phpstan to figure out the potential bugs in our project.
./vendor/bin/phpstan analyse app
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 %
It can be executed as a lua package using,
:lua require'phpstan'
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,
- Create a namespace using nvim_create_namespace()
- Generate a table of Diagnostic
- 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"
}
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()
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
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])
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": []
}
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
Now we have the table of diagnostic messages. Lets just publish it to the buffer.
vim.diagnostic.set(namespace, bufnr, diagnostics) -- 3. Publishing
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
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()
Reopen your neovim and then you will see the diagnostic messages.
Thanks