📝 Note
TL;DR: I wrote a small Neovim plugin in Lua to control the Hugo dev server. It was my first plugin attempt—and it turned out to be easier than expected!What is the base idea?
When I want to learn something – be a new language, framework or tool – I always start a basic project. During this basic project I getting familiar with the tooling, features and functions.
My idea as a basic project for Neovim is to integrate the hugo command line interface with it. I am pretty sure that somebody has already done it before me, but I am still doing it. Why? It doesn’t matter if somebody has already implemented something similar – because for learning, the process is more important then the result. If I build a new service for business, of course, it does matter if anybody else has something similar. But in this case, I am doing it myself to understand Neovim better and better.
I thought integrating Neovim with Hugo would be simple. My goals were:
- Add commands to start/stop Hugo development server.
- If I close Neovim, then stop the background process.
- Auto start functionality if project is Hugo based that is opened.
In this post, I just don’t want to talk about the final plugin, but rather write about the process how I made this plugin step by step. If anybody would be interested in the final project, it can be found on GitHub: onlyati/hugo.nvim.
Starter plugin
My starter plugin file structure looks like the following:
$ tree .
.
├── LICENSE
├── lua
│ └── hugo-server
│ ├── health.lua
│ └── init.lua
└── README.md
First, I would discuss about init.lua file. The health.lua is discussed later.
So this init.lua file is the entry point for the plugin. Its started code looks
like:
local M = {}
--- Setup function for the plugin
function M.setup()
vim.api.nvim_create_user_command("HugoStart", M.start, {})
vim.api.nvim_create_user_command("HugoStop", M.stop, {})
end
--- Start Hugo server
function M.start() end
--- Stop Hugo server
function M.stop() end
--- Return with the module
return M
Let the plugin do something visible
This does not do anything else, just defines two commands for start and stop actions. Let expand it with a notification. So I can see that command is really triggered.
local M = {}
--- Setup function for the plugin
function M.setup()
vim.api.nvim_create_user_command("HugoStart", M.start, {})
vim.api.nvim_create_user_command("HugoStop", M.stop, {})
end
--- Wrapper around vim.notify
---@param msg string
---@param level integer|nil
function M.notify(msg, level)
vim.notify(msg, level, {
title = "Hugo server",
timeout = 3000,
})
end
--- Start Hugo server
function M.start()
M.notify("This is start", vim.log.levels.INFO)
end
--- Stop Hugo server
function M.stop()
M.notify("This is stop", vim.log.levels.INFO)
end
--- Return with the module
return M
I would notice two things about the code above:
- I made a dedicated notify function, so if I want to modify it later globally then I can do it at one place. Besides I don’t have to specify its options at every place when I make a notification.
- Although Lua is dynamically typed, I like using annotations since the Lua LSP understands them. I am mostly comfortable with data types, I prefer them over dynamic type systems.
If you’re using LazyVim, you can set up the plugin like this to access the new
commands in ~/.config/nvim/lua/plugins/hugo-server.lua file:
return {
dir = "~/nvim_plugins/hugo-server",
name = "hugo-server",
config = function()
require("hugo-server").setup()
end,
}
Implement start and stop
To base idea to start and keep this process running is the following:
- Start hugo server and save its process to a variable
- Bind pipes for the stdout and stderr of the process
- If anything comes in any pipe, write it as notification
If stop is issued, process handler performs a kill action. I created three new variable.
---@type uv.uv_process_t|nil
local handle = nil
---@type uv.uv_pipe_t|nil
local stdout = nil
---@type uv.uv_pipe_t|nil
local stderr = nil
Read parameters
Before write any start command, I want to give freedom to decide. These settings can be specified in the configuration of plugin.
- Name of hugo exec (default: hugo)
- Arguments for hugo exec (default:
{ "server", "-D", "--noHTTPCache" }) - Set auto start (default:
false)
I created a class for it, and an internal options table to store the defaults.
---@class HugoOptions
---@field hugo_cmd? string
---@field args string[]
---@field auto_start boolean
--- Hugo default options
---@type HugoOptions
M.opts = {
hugo_cmd = "hugo",
args = { "server", "-D", "--noHTTPCache" },
auto_start = false,
}
In the setup() function, I specify an options parameter and merge table with
the one that is coming from the plugin configuration.
--- Setup function for the plugin
---@param opts HugoOptions|nil
function M.setup(opts)
M.opts = vim.tbl_deep_extend("force", M.opts, opts or {})
vim.api.nvim_create_user_command("HugoStart", M.start, {})
vim.api.nvim_create_user_command("HugoStop", M.stop, {})
end
The plugin configuration can have following value, which is in
~/.config/nvim/lua/plugins/hugo-server.lua file.
return {
dir = "~/nvim_plugins/hugo-server",
name = "hugo-server",
config = function()
require("hugo-server").setup({
hugo_cmd = "hugo",
args = {
"server",
"--disableFastRender",
"-D",
"--bind",
"127.0.0.1",
},
auto_start = true,
})
end,
}
So, when plugin setup is in-progress, following actions happens on high-level:
- Plugin has a default configuration.
- Read configuration from plugin configuration and save them into
M.opts. - Define
:HugoStartand:HugoStopcommands.
Start the server
Now, configuration is ready, let’s start implement the start function. To handle process I use uv.spawn function.
At the beginning of code, I define a local variable for uv. This just makes
the code a bit shorter.
local `uv` = vim.uv
In the following, the whole start function is visible, I try to make meaningful comments to understand it better.
--- Start Hugo server
function M.start()
-- If handle is not nil, it means it has been already started.
-- There is no meaning to start it twice.
if handle then
M.notify("Hugo server is already running", vim.log.levels.WARN)
return
end
-- Initialize pipes for output, no need for any stdin pipe
stdout = uv.new_pipe(false)
stderr = uv.new_pipe(false)
local pid = nil
---@diagnostic disable-next-line
handle, pid = uv.spawn(M.opts.hugo_cmd, {
args = M.opts.args,
stdio = { nil, stdout, stderr },
}, function(code, signal)
-- Normally this part of code only run when hugo server is stopped
vim.schedule(function()
M.notify(string.format("Hugo exited (code: %d, signal: %d)", code, signal), vim.log.levels.INFO)
end)
handle = nil
end)
-- It is not sure that `handle` can be nil, but better for a check
if not handle then
M.notify("[hugo] failed to start", vim.log.levels.ERROR)
return
end
M.notify("[hugo] started, pid: " .. pid, vim.log.levels.INFO)
-- Read stdout, and write the non empty messages
if stdout ~= nil then
stdout:read_start(function(err, data)
assert(not err, err)
if data and vim.trim(data) ~= "" then
vim.schedule(function()
M.notify("[hugo] " .. data, vim.log.levels.INFO)
end)
end
end)
end
-- Read stderr, and write the non empty messages
if stderr ~= nil then
stderr:read_start(function(err, data)
assert(not err, err)
if data and vim.trim(data) ~= "" then
vim.schedule(function()
M.notify("[hugo error] " .. data, vim.log.levels.ERROR)
end)
end
end)
end
end
Stop the server
Stop the server is much more simpler. The whole code is that hundle call the
kill method to kill himself.
--- Stop Hugo server
function M.stop()
if not handle then
M.notify("Hugo server is not running", vim.log.levels.WARN)
return
end
handle:kill("sigterm")
handle = nil
M.notify("Hugo server stopped", vim.log.levels.INFO)
end
Fixing problems
The plugin is working at this point and functional. But I have found some issues.
Automatic cleanup during Neovim close
If I start the server, and exit from Neovim, server remain running! To solve
this problem I add an auto command for Neovim for VimLeavePre event in the
setup() function. To be honest, I’m not 100% sure VimLeavePre is the best event
– but it works reliably so far.
--- Setup function for the plugin
---@param opts HugoOptions|nil
function M.setup(opts)
M.opts = vim.tbl_deep_extend("force", M.opts, opts or {})
vim.api.nvim_create_user_command("HugoStart", M.start, {})
vim.api.nvim_create_user_command("HugoStop", M.stop, {})
vim.api.nvim_create_autocmd("VimLeavePre", {
callback = function()
if handle then
handle:kill("sigterm")
end
end,
})
end
Add lazy load
I like the thinking of lazy load: do not load everything just that is required. I only want to load this function if one of the following event happens:
- User execute any command
- User open a project that has a hugo configuration file
I solved both in the plugin configuration.
return {
dir = "~/nvim_plugins/hugo-server",
name = "hugo-server",
-- Lua script looking for any hugo config file, if found then load it
init = function()
local uv = vim.uv
local cwd, _, _ = uv.cwd()
for _, v in ipairs({ "hugo.toml", "hugo.yaml", "hugo.json", "hugo.yml" }) do
local path = cwd .. "/" .. v
if uv.fs_stat(path) then
require("lazy").load({ plugins = { "hugo-server" } })
end
end
end,
config = function()
require("hugo-server").setup({
hugo_cmd = "hugo",
args = {
"server",
"--disableFastRender",
"-D",
"--bind",
"127.0.0.1",
},
auto_start = true,
})
end,
-- Define commands, so LazyVim would know if I issue one of these
-- commands then they are belongs here.
cmd = { "HugoStart", "HugoStop" },
}
I’m also a bit lazy, I added key mappings to make my life easier
Always type commands that I registered, seems too long time for me. So, I have also added some key map bindings to start and stop server. The configuration below is my final configuration.
return {
dir = "~/nvim_plugins/hugo-server",
name = "hugo-server",
init = function()
local uv = vim.uv
local cwd, _, _ = uv.cwd()
for _, v in ipairs({ "hugo.toml", "hugo.yaml", "hugo.json", "hugo.yml" }) do
local path = cwd .. "/" .. v
if uv.fs_stat(path) then
require("lazy").load({ plugins = { "hugo-server" } })
end
end
end,
config = function()
require("hugo-server").setup({
hugo_cmd = "hugo",
args = {
"server",
"--disableFastRender",
"-D",
"--bind",
"127.0.0.1",
},
auto_start = true,
})
end,
cmd = { "HugoStart", "HugoStop" },
keys = {
{ "<leader>hs", "<cmd>HugoStart<cr>", desc = "Hugo Start" },
{ "<leader>hp", "<cmd>HugoStop<cr>", desc = "Hugo Stop" },
{ "<leader>h", desc = "+hugo" },
},
}
Implement auto start
I want to give option to start hugo server when project is open, but it is an
opt-in function. I prefer opt-in features like this so the plugin doesn’t behave
unexpectedly in unrelated projects. I have added an extra function
and check in setup() function.
--- Setup function for the plugin
---@param opts HugoOptions|nil
function M.setup(opts)
M.opts = vim.tbl_deep_extend("force", M.opts, opts or {})
vim.api.nvim_create_user_command("HugoStart", M.start, {})
vim.api.nvim_create_user_command("HugoStop", M.stop, {})
vim.api.nvim_create_autocmd("VimLeavePre", {
callback = function()
if handle then
handle:kill("sigterm")
end
end,
})
if M.opts.auto_start and M.is_it_hugo_project() then
M.notify("Detected Hugo project", vim.log.levels.WARN)
M.start()
end
end
--- Check if current directory is a Hugo project
---@return boolean
function M.is_it_hugo_project()
local cwd, _, _ = uv.cwd()
for _, v in ipairs({ "hugo.toml", "hugo.yaml", "hugo.json", "hugo.yml" }) do
local path = cwd .. "/" .. v
if uv.fs_stat(path) then
return true
end
end
return false
end
Final words
This was my started plugin that I have wrote, mainly from education purpose:
- Get familiar with Neovim functions.
- Get familiar with Neovim documentation.
- Get familiar with plugin structure.
In my opinion, it was surprisingly easy to write this small plugin. Starting from zero knowledge, I was able to complete it in about an hour. I like the system on Neovim, but I have to notice: this was a very simple and basic plugin. My opinion might be changed later, when I would write more complex ones.
Bonus tip: Write a function for check health
Neovim has a command called :checkhealth (or shortly :che). I wanted to
make a checker for that, that is checking if hugo exec is available. To fulfill
this quest, I had to create a health.lua script.
The tricky part, that I gave option to specify hugo exec name and the health
check function in a separated module. So first, health check must read the
hugo-server plugin.
local M = {}
M.check = function()
local plugin_ok, plugin = pcall(require, "hugo-server")
if not plugin_ok then
vim.health.error("Failed to load hugo-server plugin")
return
end
vim.health.start("Looking for binary '" .. plugin.opts.hugo_cmd .. "'")
if vim.fn.executable(plugin.opts.hugo_cmd) == 1 then
vim.health.ok("Binary has been found")
else
vim.health.error("Binary is missing")
end
end
return M
