LSP: project-specific settings

I’ve had the need to have project specific settings for my LSP servers for a while now. I’ve been asked to create a discourse post about this to explore my use case and possible solutions.

The reason I need project-specific settings is that I’m working on Ada programs and using the Ada Language Server. The Ada programming language has a build tool named GPRBuild, which you can think of as somewhere in-between Cmake and Cargo. GPRBuild is driven by GPRFiles (files whose extension is .gpr, but with not other standard naming scheme) and in order to be able to correctly resolve dependencies, the Ada Language Server needs to be able to read GPRFiles. It’s not uncommon for projects to have multiple GPRFiles, and for the user to choose one or the other depending on what they’re trying to do. You can configure what GPRFile the Ada Language Server should use by specifying settings, e.g.

require("lspconfig").als.setup{ settings = { ada = { projectFile = "gnat.gpr" } } }

However, I need something more flexible - I’m only going to use the gnat.gpr file when working on Gnat, other projects will use other gpr files.

The officially-recomended approach is to use an exrc: add a file to your project directory that will be automatically executed by neovim on startup and use that to configure the LSP.

I have three problems with that approach though:

  1. I never start neovim in my project’s repository, so exrc files aren’t read.
  2. I don’t like litering my project directories with editor-specific files.
  3. I don’t like the security implications.

On matrix, I’ve been advised to look into the on_init hook and the workspace/didChangeConfiguration notification. It required reading Neovim’s source code, but I eventually managed to figure out how to use them in order to do what I need:

local function als_on_init(client)
  client.config.settings.ada = { projectFile = "gnat.gpr" }
  client.notify("workspace/didChangeConfiguration")
  return true
end
require("lspconfig").als.setup{ on_init = als_on_init }

This piece of code lets me programmatically choose what settings to send to the ALS no sooner than when the neovim client is connecting to it, and without having to source any external files! Many thanks to @clason, @mfussenegger and @mjlbach for their advice :).

4 Likes

Thanks for sharing ! I prefer to use the project-specific approach but your concerns are valid and it’s nice that you’'ve shared your solution.

1 Like

Loading Vimscript (or Lua) automatically on the fly is insecure. However, loading JSON should be safe since it cannot contain executable code. I have several rules set up for Lua so I can load the correct settings for different projects (because of how Lua is supposed to be embedded there is no default Lua use case). One of the rules looks like this:

function M.has_local_settings_file()
	return filereadable('lua-lsp.json')
end

Then I can do something along these lines:

local config = {...}  -- Default configuration
if (has_local_settings_file()) then
    config.settings = function ()
        vim.fn.json_decode(vim.fn.readfile('lua-lsp.json'))
    end,
end

I have a few more rules and the setup is a bit more compilcated, but that’s the gist of it. You still have to create an extra file, but it works for language servers which do not have a configuration mechanism built in.

2 Likes

I’m working on the lua code below, which searches for gpr files upwards and prompts the user to select one. However, it seems that if the on_init function doesn’t return immediately, the language server goes ahead without the settings, instead of waiting for on_init to return. The idea is to turn this into a plugin so that the user can open a .gpr file and run some command to set that as the currently active file, and to make it automatically ask when there is ambiguity.

function mysplit (inputstr, sep)
        if sep == nil then
                sep = "%s"
        end
        local t={}
        for str in string.gmatch(inputstr, "([^"..sep.."]+)") do
                table.insert(t, str)
        end
        return t
end

local function get_gpr_files_in_dir(dir)
  return split(vim.fn.glob(dir.."/*.gpr"))
end

local function parent_of(dir)
  return dir:match("(.*)/")
end

-- search upwards to find .gpr files and return their paths
-- TODO if in a git dir, search up until that git root,
-- otherwise, search until at least one .gpr file was found
local function find_gprfiles()
  local project_files = {}
  local dir = parent_of(vim.api.nvim_buf_get_name(0))
  while #project_files == 0 do
    project_files = get_gpr_files_in_dir(dir)
    if #project_files == 0 then
      dir = parent_of(dir)
    end
    project_files = get_gpr_files_in_dir(dir)
  end
  return project_files
end

local function get_choice_list(choices)
  local list = ""
  for number, item in ipairs(choices) do
    list = list .. "[" .. number .. "] " .. item .. "\n"
  end
  return list
end

local function select_gpr_file(file)
  vim.g["als_gpr_projectfile"] = file
  print("Loaded " .. file)
end

local function als_on_init(client)
  if (vim.g.als_gpr_projectfile or "") == "" then
    local projectfiles = find_gprfiles()
    -- select automatically if 1 option,
    if #projectfiles == 1 then
      select_gpr_file(projectfiles[1])
    else
      -- else ask which project file to use
      local prompt = "Choose a GPR project file:"
      local choice_list = get_choice_list(projectfiles)
      local choice = vim.fn.input(prompt .. "\n" .. choice_list)
      select_gpr_file(projectfiles[choice])
    end
  end
  client.config.settings.ada = { projectFile = vim.g.als_gpr_projectfile }
  client.notify("workspace/didChangeConfiguration")
  return true
end

require("lspconfig").als.setup{
  on_attach = on_attach,
  capabilities = capabilities,
  on_init = als_on_init
}

It’s alive!