-- This module contains the logic responsible for building and starting
-- the Golang server. The Go server is responsible for making API calls
-- to Gitlab and returning the data
local state = require("gitlab.state")
local u = require("gitlab.utils")
local client = require("gitlab.client")
local version = require("gitlab.version")
local GitlabGroup = require("gitlab.autocmd")
local M = {}
---@type vim.SystemObj?
local server_system_obj
local kill_autocmd_created = false
-- Set when the VimLeavePre autocmd kills the server, so that the server's
-- on_exit callback skips the "server exited" error notification: it would
-- otherwise fire (and might fail to render) while nvim is tearing down.
local killed_on_exit = false
-- Builds the binary if it doesn't exist, and starts the server. If the pre-existing binary has an older
-- tag than the Lua code (exposed via the /version endpoint) then shuts down the server, rebuilds it, and
-- restarts the server again.
M.build_and_start = function(callback)
if M.build(false) == false then
return
end
-- When the user provides their own binary, skip the version check and rebuild cycle.
-- The user is responsible for keeping their binary up to date.
local user_provided_binary = state.settings.server.binary_provided
M.start(function()
if user_provided_binary then
callback()
return
end
M.get_version(function(versions)
if versions.plugin_version ~= versions.binary_version then
M.shutdown(function()
if M.build(true) then
M.start(callback)
end
end)
else
callback()
end
end)
end)
end
---Starts the Go server and call the callback provided
M.start = function(callback)
if state.settings.port ~= nil and state.settings.server.port == nil then
state.settings.server.port = state.settings.port
u.notify("The setting `port` has been renamed `server.port`", vim.log.levels.WARN)
end
local port = tonumber(state.settings.server.port) or 0
local parsed_port = nil
local callback_called = false
local go_server_settings = {
gitlab_url = state.settings.gitlab_url,
port = port,
auth_token = state.settings.auth_token,
debug = state.settings.debug,
log_path = state.settings.log_path,
connection_settings = state.settings.connection_settings,
chosen_mr_iid = state.chosen_mr_iid,
}
state.chosen_mr_iid = 0 -- Do not let this interfere with subsequent reviewer.open() calls
local settings = vim.json.encode(go_server_settings)
if not kill_autocmd_created then
kill_autocmd_created = true
vim.api.nvim_create_autocmd({ "VimLeavePre" }, {
callback = function()
if server_system_obj == nil then
return
end
killed_on_exit = true
-- Send SIGKILL rather than SIGTERM: the server is a stateless proxy to the
-- Gitlab API, and a graceful shutdown would wait for in-flight requests.
-- Use `pcall` because the handle is already closed if the process exited on
-- its own, e.g. through the /shutdown endpoint.
pcall(function()
server_system_obj:kill("sigkill")
end)
server_system_obj = nil
end,
desc = "Kill the gitlab.nvim Go server process",
group = GitlabGroup,
})
end
local ok, obj = pcall(vim.system, { state.settings.server.binary, settings }, {
-- The `stdin` param opens a pipe on the server's stdin that nvim holds open but
-- never writes to. The server exits when it sees EOF on the pipe, which is how it
-- notices that nvim is gone in the cases the VimLeavePre autocmd cannot cover:
-- SIGKILL, an OOM kill, or a crash.
stdin = true,
stdout = function(_, data)
if data == nil or parsed_port ~= nil then
return
end
for line in data:gmatch("[^\r\n]+") do
local matched = line:match("Server started on port:%s+(%d+)")
if matched ~= nil then
parsed_port = matched
vim.schedule(function()
state.settings.server.port = matched
state.go_server_running = true
if not callback_called then
callback_called = true
callback()
end
end)
break
end
end
end,
}, function(out)
if out.code ~= 0 and not killed_on_exit then
vim.schedule(function()
local msg = "Golang gitlab server exited: code: " .. out.code .. ", signal: " .. (out.signal or 0)
if out.stderr ~= "" then
msg = msg .. ", msg: " .. vim.trim(out.stderr)
end
u.notify(msg, vim.log.levels.ERROR)
end)
end
end)
if ok then
server_system_obj = obj
else
u.notify("Could not start gitlab.nvim binary: " .. tostring(obj), vim.log.levels.ERROR)
end
end
-- Builds the Go binary with the current Git tag.
M.build = function(override)
state.settings.root_path = u.get_root_path()
-- If the user provided a path to the server, don't build it.
if state.settings.server.binary_provided then
return
end
local version_issue = version.check_go_version()
if version_issue ~= nil then
return
end
-- If the user did not provide a path, we build it and place it in either the data path, or the
-- first writable path we find in the runtime.
local datapath = vim.fn.stdpath("data")
local runtimepath = vim.api.nvim_list_runtime_paths()
table.insert(runtimepath, 1, datapath)
local bin_name = u.is_windows() and "server.exe" or "server"
local bin_folder
for _, path in ipairs(runtimepath) do
local ok, err = vim.loop.fs_access(path, "w")
if err == nil and ok ~= nil and ok then
bin_folder = path .. u.path_separator .. "gitlab.nvim" .. u.path_separator .. "bin"
if vim.fn.mkdir(bin_folder, "p") == 1 then
state.settings.server.binary = bin_folder .. u.path_separator .. bin_name
break
end
end
end
if state.settings.server.binary == nil then
u.notify("Could not find a writable folder in the runtime path to save the server to.", vim.log.levels.ERROR)
return
end
if not override then
local binary_exists = vim.loop.fs_stat(state.settings.server.binary)
if binary_exists ~= nil then
return
end
end
local version_output = vim
.system({ "git", "describe", "--tags", "--always" }, { cwd = state.settings.root_path })
:wait()
local plugin_version = version_output.code == 0 and vim.trim(version_output.stdout) or "unknown"
local ldflags = string.format("-X main.Version=%s", plugin_version)
local res = vim
.system(
{ "go", "build", "-buildvcs=false", "-ldflags", ldflags, "-o", state.settings.server.binary },
{ cwd = state.settings.root_path .. u.path_separator .. "cmd" }
)
:wait()
if res.code ~= 0 then
u.notify(string.format("Failed to install with status code %d:\n%s", res.code, res.stderr), vim.log.levels.ERROR)
return false
end
u.notify("Installed successfully!", vim.log.levels.INFO)
return true
end
-- Shuts down the Go server and clears out all old gitlab.nvim state
M.shutdown = function(cb)
if not state.go_server_running then
vim.notify("The gitlab.nvim server is not running", vim.log.levels.ERROR)
return
end
client.send_request("/shutdown", "POST", { restart = false }, function(data)
state.go_server_running = false
state.clear_data()
if cb then
cb()
else
u.notify(data.message, vim.log.levels.INFO)
end
end)
end
---Restarts the Go server and clears out all gitlab.nvim state
M.restart = function(cb)
if not state.go_server_running then
vim.notify("The gitlab.nvim server is not running", vim.log.levels.ERROR)
return
end
client.send_request("/shutdown", "POST", { restart = true }, function(data)
state.go_server_running = false
M.start(function()
state.clear_data()
if cb then
cb()
else
u.notify(data.message, vim.log.levels.INFO)
end
end)
end)
end
-- Runs `callback` with the version (git tag) that was baked into the binary when it was last built.
M.get_version = function(callback)
if not state.go_server_running then
u.notify("Gitlab server not running", vim.log.levels.ERROR)
return
end
local parent_dir = u.get_root_path()
local version_output = vim.system({ "git", "describe", "--tags", "--always" }, { cwd = parent_dir }):wait()
local plugin_version = version_output.code == 0 and vim.trim(version_output.stdout) or "unknown"
-- We call the "/version" endpoint here instead of through the regular send_request
-- pattern because earlier versions of the plugin may not have the endpoint. We handle
-- a 404 as an "unknown" version error.
local cmd = {
"curl",
"--noproxy",
"localhost",
"-s",
"-X",
"GET",
string.format("localhost:%s/version", state.settings.server.port),
}
local result = vim.system(cmd):wait()
local _, data = pcall(vim.json.decode, result.stdout)
local binary_version = data and data.version and data.version or "unknown"
callback({ plugin_version = plugin_version, binary_version = binary_version })
end
return M