A plug in for neovim that interfaces the dotnet cli command.
The Pain of Managing Package References and Solution Files
Neovim is a fantastic editor that offers a keyboard-centric workflow. However, as a .NET developer, managing package references and solution files (.sln) can become cumbersome as I have to switch to a terminal. This post demonstrates how Neovim’s extensibility enables me to simplify the annoyances in my workflow.
The Issue with Using :!dotnet <args>
or :terminal dotnet <args>
A common workaround for interacting with the .NET CLI from within Neovim is to use the :!dotnet <args>
or :terminal dotnet <args>
commands. While this works, there is a minor drawback. This method of interacting with dotnet commands execute in the context of the directory you launched Neovim. This gets tedious if you have to type out long directory paths.
Requirements
We want to write a Neovim plugin dubbed dotnet-nvim
that will do the following:
- Execute the dotnet cli command when you run
:Dotnet
. - The
Dotnet
command should execute with the working directory for the active buffer. - There should be completions for the subcommands. i.e
dotnet sln
,dotnet add
,dotnet restore
, Etc. - There should be completions for paths that follow commands like
dotnet sln add
.
The stack
Plugin project structure
The first thing is first. The plugin will follow the following structure.
dotnet-nvim/
└─── lua/ # Lua modules for the plugin
└── dotnet-nvim/ # Main Lua file for the plugin
└─── init.lua # Initialization and configuration
Installing it on your local nvim lazy vim config
To test our plugin, we will install it using Lazy like so. In this example, I have my config setup such that each plugin is in its file.
return {
dir = 'path/to/plugin/dotnet-nvim',
config = function()
require('dotnet-nvim').setup()
end,
}
Plugin Creating the Dotnet command
In the init.lua
file for the plugin, we will add a function called setup, this is a function that
will be used to setup the plugin. In this function, we use the vim.api.nvim_create_user_command
function to create a new command and set it up to print ‘We are dotnetting’.
local M = {}
function M.setup()
vim.api.nvim_create_user_command('Dotnet', function(opts)
print 'We are dotnetting'
end, {})
end
return M
This should print the text ‘We are dotnetting’ when you run :Dotnet
after reloading nvim.
To run the dotnet
command we will add a helper function for running commands to the command
line.
-- Helper function to run shell commands and capture output
local function run_command(cmd, cwd)
local handle
if cwd then
-- io.popen is used to start a new process to run the command
handle = assert(io.popen('cd ' .. vim.fn.shellescape(cwd) .. ' && ' .. cmd, 'r'))
else
handle = assert(io.popen(cmd, 'r'))
end
-- we read the output from the process stdout here
local result = handle:read '*a'
handle:close()
return result
end
We will update our setup function to use the run_command
function as follows:
function M.setup()
vim.api.nvim_create_user_command('Dotnet', function(opts)
local cmd = 'dotnet ' .. table.concat(opts.fargs, ' ')
local cwd = vim.fn.expand '%:p:h'
print(run_command(cmd, cwd) .. '\n')
end, {
nargs = '*', -- Accept multiple arguments
})
end
- Notice we have added
nargs = '*'
to the last parameter ofvim.api.nvim_create_user_command
. This allows the command to accept multiple arguments - We are using
vim.fn.expand '%:p:h'
to get the path for the current buffer.
And with that, we are able to run the dotnet command within nvim.
Add completions
We will now add a function that will handle the completions. We will now add a function that will handle the completions. This function takes in user input and provides relevant suggestions based on the context of the dotnet command being executed.
local function complete_dotnet(arg_lead, cmd_line)
local args = vim.split(cmd_line, '%s+')
local context = args[2] or ''
local completions = {}
local cwd = vim.fn.expand '%:p:h'
if context == 'add' then
if args[3] == 'reference' then
completions = get_path_completions(cwd, args[4] or '')
else
completions = { 'reference', 'package' }
end
elseif context == 'sln' then
if args[3] == 'add' or args[3] == 'remove' then
completions = get_path_completions(cwd, args[4] or '')
else
completions = { 'add', 'remove' }
end
else
completions = {
'sln',
'new',
'build',
'run',
'test',
'publish',
'restore',
'clean',
'add',
}
end
local matches = {}
for _, item in ipairs(completions) do
if item:match('^' .. vim.pesc(arg_lead)) then
table.insert(matches, item)
end
end
return matches
end
Completions for dotnet subcommands are just a static list. We also have completions for file paths
when we run commands like dotnet sln add
. Here Is The implementation of the get_path_completions
function that is used for this.
-- Helper function to list directories and files under a given path
function get_path_completions(path, prefix)
local itemsString = vim.fn.glob(path .. '/' .. prefix .. '*')
local items = vim.split(itemsString, '\n', { trimempty = true })
local matches = {}
for _, item in ipairs(items) do
table.insert(matches, string.sub(item, #path + 2))
end
return matches
end
We bring all of this together by passing an option in the vim.api.nvim_create_user_command
function.
function M.setup()
vim.api.nvim_create_user_command('Dotnet', function(opts)
local cmd = 'dotnet ' .. table.concat(opts.fargs, ' ')
local cwd = vim.fn.expand '%:p:h'
local output = run_command(cmd, cwd)
vim.api.nvim_out_write(output .. '\n')
end, {
nargs = '*', -- Accept multiple arguments
complete = complete_dotnet,
})
end
And this completes the stack. Have a look at the complete solution which includes completions for nuget packages and includes some rules on running the command at a solution level for some commands.