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 argumentanalyse
is to tell phpstan to analyse. The third argumentapp
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 numerouslua
apis to build a plugin. In fact the neovim source code has morelua
code thanc
. Wait what isLua
? 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 alua/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 documenthere. As per the document,the flow would be,
- Create a namespace usingnvim_create_namespace()
- Generate a table of Diagnostic
- Set the diagnostics to the buffer
Take a moment to look at thevim.Diagnostic
interfacehere
Most of them are optional keys, this is going to be our diagnostic table
localdiagnostic={lnum=1,col=0,message="",severity=vim.diagnostic.Severity.ERROR,code="class.NotFound"}
Plenary
The idea is to execute thephpstan
command from lua and parse the output. But by defaultphpstan
stdout is a table and it is difficult to parse. But it supports multiple output formats. We are gonna choosejson
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’shttps://github.com/nvim-lua/plenary.nvim. It helps us to interact with system commands in async way by providing nice and apis.
localJob=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 diagnosticsend),}):start()
Lets parse the output
First we should check thereturn_val
. It the exit code of the command that we execute. It the current file does not have any errors, the exit code must be0
. So only the non zero exit code will have the output.
Lets eliminate the first
ifreturn_val==0thenreturnend
The objectj
has the result in a table. Each line would be a separate element in the table. Since the error format isjson
, all the json string will be in a single line. Vim has a built-in functionvim.json.decode
to decode the json string and convert it to a lua table.
localresult=j:result()localresponse=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. Thefiles
element contains all the files. The each file contains an array of messages. Yes, you got it.
localnamespace=vim.api.nvim_create_namespace('phpstan')-- 1. Remember the first step of the vim.Diagnostic above-- 2. Generating the diagnostic messages tableforfile_pathinpairs(response.files)dolocalbufnr=vim.fn.bufnr(file_path);localdiagnostics={}foriinpairs(response.files[file_path].messages)dolocalmessage=response.files[file_path].messages[i]localdiagnostic={bufnr=bufnr,lnum=message.line,col=0,-- Since phpstan does not support columns, setting it to zeromessage=message.message,code=message.identifier,}ifmessage.ignorablethendiagnostic.severity=vim.diagnostic.severity.WARNelsediagnostic.severity=vim.diagnostic.severity.ERRORendtable.insert(diagnostics,diagnostic)endend
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 aautocmd
. 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
localJob=require'plenary.job'localM={}localnamespace=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)ifreturn_val==0thenreturnendlocalresult=j:result()localresponse=vim.json.decode(result[1])forfile_pathinpairs(response.files)dolocalbufnr=vim.fn.bufnr(file_path);localdiagnostics={}foriinpairs(response.files[file_path].messages)dolocalmessage=response.files[file_path].messages[i]localdiagnostic={bufnr=bufnr,lnum=message.line,col=0,message=message.message,source=message.tip,code=message.identifier,namespace=namespace}ifmessage.ignorablethendiagnostic.severity=vim.diagnostic.severity.WARNelsediagnostic.severity=vim.diagnostic.severity.ERRORendtable.insert(diagnostics,diagnostic)endvim.diagnostic.set(namespace,bufnr,diagnostics)endend),}):start()endM.setup=function()-- Registering auto command to the files that ends with the php extensionvim.api.nvim_create_autocmd({"BufReadPre","BufWritePost"},{pattern={"*.php"},callback=M.analyse})endreturnM
We are returning a Lua table, we should call thesetup
in your neovim’s init file.
In my case itsinit.vim
luarequire('phpstan').setup()
Reopen your neovim and then you will see the diagnostic messages.
Thanks
Top comments(0)
For further actions, you may consider blocking this person and/orreporting abuse