📝 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:

  1. 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.
  2. 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:

  1. Start hugo server and save its process to a variable
  2. Bind pipes for the stdout and stderr of the process
  3. 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:

  1. Plugin has a default configuration.
  2. Read configuration from plugin configuration and save them into M.opts.
  3. Define :HugoStart and :HugoStop commands.

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