aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md27
-rw-r--r--init.lua83
-rw-r--r--settings.lua206
-rw-r--r--util.lua53
-rwxr-xr-xutil_test.lua43
5 files changed, 386 insertions, 26 deletions
diff --git a/README.md b/README.md
index 4fb14d2..806af12 100644
--- a/README.md
+++ b/README.md
@@ -169,6 +169,22 @@ ls_map.lua = {
Language servers configured in `vis-lspc` can be found in `supported_servers.lua`.
+#### Language Server Settings
+
+Language servers can be configured with different mechanisms, all merged together to form the *effective* settings for a language server instance.
+There are three kinds of settings:
+
+1. *Global settings* specified by the user in its `vis` configuration in a language server configuration's `settings` member.
+2. *Project local* settings stored in a `.vis-lspc-settings.json` file along your regular project files.
+3. Settings stored by `vis-lspc` in the `settings.json` file.
+ The *client local* user settings are stored for each language server and each file path.
+ Settings for a more specific file path override settings defined for a parent directory.
+
+The *project* and *client local* settings are indexed by the name of the language server.
+
+All relevant settings are merged to form the *effective* settings passed to the language server.
+More specific settings take precedence before more general ones.
+
### Workspace Detection
During server initialization an URI to the root of the workspace (a folder opened by the editor) can be passed to the server.
@@ -210,6 +226,17 @@ lspc_open(win, path, line, col, cmd)
- `col` - same as `line`, but for the column.
- `cmd` - `vis` command to open the file. (`e` or `o`, see `vis` commands)
+#### `lspc.get_running_ls`
+
+Get a running language server table.
+
+```lua
+get_running_ls(win, explicit_syntax)
+```
+
+ - `win` - a window in which the language server is running
+ - `explicit_syntax` - syntax of the language server if it differs from `win.syntax`
+
## License
All code except otherwise noted is licensed under the term of GPL-3.
diff --git a/init.lua b/init.lua
index a47ad22..9a40bf9 100644
--- a/init.lua
+++ b/init.lua
@@ -34,6 +34,9 @@ local parser = dofile(source_path .. 'parser.lua')
-- initialise the util module
local util = dofile(source_path .. 'util.lua').init(lspc)
+-- initialise the settings module
+local settings = dofile(source_path .. 'settings.lua').init(lspc)
+
-- load a suitable json module
lspc.json = dofile(source_path .. 'json.lua')
@@ -85,7 +88,9 @@ end
-- map of known language servers per syntax
lspc.ls_map = dofile(source_path .. 'supported-servers.lua')
--- return the name of the language server for this syntax
+--- Return the name of the language server for this syntax
+-- @param syntax the syntax the language server should support
+-- @return the name of the languauge server configured for syntax
local function get_ls_name_for_syntax(syntax)
local ls_def = lspc.ls_map[syntax]
if not ls_def then
@@ -951,15 +956,24 @@ local function lspc_handle_initialize_response(ls, result)
-- According to nvim-lspconfig sendig the lsp server settings shortly after
-- initialization is an undocumented convention.
-- See https://github.com/neovim/nvim-lspconfig/blob/ed88435764d8b00442e66d39ec3d9c360e560783/CONTRIBUTING.md
- if ls.settings then
- ls:send_notification('workspace/didChangeConfiguration', {
- settings = ls.settings,
- })
- end
+ lspc:log('Loading settings for ' .. ls.name)
+ ls:send_default_settings()
vis.events.emit(lspc.events.LS_INITIALIZED, ls)
end
+--- Send didChangeConfiguration notification with the default settings
+function LanguageServer:send_default_settings()
+ -- Use the rootUri or the open file's path as scope for the initial settings
+ local scope = self.rootUri or vis.win.file and vis.win.file.path
+ local effective_ls_settings = settings.effective_settings(self, nil, scope)
+ if next(effective_ls_settings) then
+ self:send_notification('workspace/didChangeConfiguration', {
+ settings = effective_ls_settings,
+ })
+ end
+end
+
--- Dispatch method response from the server
-- @param method_response the response send from the server
-- @param req the request causing this response
@@ -1027,19 +1041,19 @@ function LanguageServer:handle_method_response(method_response, req)
self.inflight[method_response.id] = nil
end
+--- Handle a workspace/configuration request from a server
+-- @param params the parameters send with the request
+-- @param response the response we are about to send
local function lspc_handle_workspace_configuration_call(ls, params, response)
local results = {}
for _, item in ipairs(params.items) do
- -- de-flatten the section and map it to our settings if one is present.
- if item.section then
- local t = ls.settings
- for k in item.section:gmatch('[^.]+') do
- if not t then
- break
- end
- t = t[k]
- end
- table.insert(results, t or lspc.json.null)
+ local scope = item.scopeUri and uri_to_path(item.scopeUri)
+ local effective_settings = settings.effective_settings(ls, item.section, scope)
+ -- If we can not provide any settings LSP requires that null is present in the result array.
+ if next(effective_settings) then
+ table.insert(results, effective_settings)
+ else
+ table.insert(results, nil)
end
end
response.result = results
@@ -1161,8 +1175,11 @@ function LanguageServer:recv_data(data)
end
end
--- check if a language server is running and initialized
-local function lspc_get_usable_ls(win, explicit_syntax)
+--- Return a running language server
+-- @param win the window for which the language server might be running
+-- @param explicit_syntax the syntax if different from win.syntax
+-- @return a running language server or nil and an error message
+function lspc.get_running_ls(win, explicit_syntax)
local ls
local syntax = explicit_syntax or (win and win.syntax)
-- try to use the first language server managing the current file
@@ -1187,6 +1204,21 @@ local function lspc_get_usable_ls(win, explicit_syntax)
end
end
+ return ls
+end
+
+--- Return a running and initialized language server
+-- @param win the window for which the language server might be running
+-- @param explicit_syntax the syntax if different from win.syntax
+-- @return a running and initialized language server or nil and an error message
+local function lspc_get_usable_ls(win, explicit_syntax)
+ local ls, err = lspc.get_running_ls(win, explicit_syntax)
+
+ if err then
+ return nil, err
+ end
+
+ assert(ls)
if not ls.initialized then
return nil, 'Language server ' .. ls.name .. ' not initialized yet. Please try again'
end
@@ -1280,7 +1312,10 @@ local function find_root_uri(ls, file_path)
end
local function ls_start(ls, init_options)
- ls.fd = vis:communicate(ls.name, 'exec ' .. ls.cmd)
+ ls.fd = vis:communicate(ls.name, 'exec ' .. ls.conf.cmd)
+
+ -- detect the workspace root
+ ls.rootUri = vis.win.file and find_root_uri(ls, vis.win.file.path)
-- register the response handler
vis.events.subscribe(vis.events.PROCESS_RESPONSE, function(name, event, code, msg)
@@ -1310,7 +1345,7 @@ local function ls_start(ls, init_options)
local params = {
processId = vis_pid,
clientInfo = {name = lspc.name, version = lspc.version},
- rootUri = (vis.win.file and find_root_uri(ls, vis.win.file.path)) or lspc.json.null,
+ rootUri = ls.rootUri or lspc.json.null,
capabilities = lspc.client_capabilites,
}
@@ -1324,10 +1359,8 @@ end
function LanguageServer.new(ls_conf)
local ls = {
name = ls_conf.name,
- cmd = ls_conf.cmd,
- settings = ls_conf.settings,
- roots = ls_conf.roots,
- formatting_options = ls_conf.formatting_options,
+ conf = ls_conf,
+
initialized = false,
id = 0,
inflight = {},
@@ -1635,7 +1668,7 @@ vis:command_register('lspc-format', function(argv, _, win)
local params = {
textDocument = {uri = path_to_uri(win.file.path)},
- options = ls.formatting_options,
+ options = ls.conf.formatting_options,
}
if params.options == nil then
params.options = {
diff --git a/settings.lua b/settings.lua
new file mode 100644
index 0000000..ed753d4
--- /dev/null
+++ b/settings.lua
@@ -0,0 +1,206 @@
+--- Collect all effective settings.
+-- There are three kinds of settings:
+--
+-- 1. Global settings explicitly set by the user in its vis configuration in the
+-- language server configuration's `settings` member.
+--
+-- 2. Project specific settings stored in .vis-lspc-settings.json files.
+--
+-- 3. Settings stored by vis-lspc in its settings.json file.
+-- The user settings are stored for each language server and each file path.
+-- Settings for a more specific file path override settings defined for
+-- a parent directory.
+--
+-- All settings are organized in sections which are their top-level organization.
+-- Commonly a language server expects its settings to be stored in a section with
+-- its name.
+-- Additionally, settings can be scoped which correspond with the local file
+-- system. The scoped settings are merged with more specific ones taking priority.
+--
+-- TODO: Implement commands to change the client settings and store them
+-- permanently.
+--
+-- @module settings
+-- @author Florian Fischer
+-- @license GPL-3
+-- @copyright Florian Fischer 2024
+local settings = {}
+
+local util
+
+local lspc
+
+--- Initialize the settings module
+-- @param the lspc module table
+-- @return the settings module table
+function settings.init(lspc_)
+ lspc = lspc_
+ local source_str = debug.getinfo(1, 'S').source:sub(2)
+ local source_path = source_str:match('(.*/)')
+
+ util = dofile(source_path .. 'util.lua').init(lspc)
+
+ return settings
+end
+
+--- Read a JSON settings file from disk
+-- @param settings_path the path to the settings file
+-- @return the settings table
+local function read_settings(settings_path)
+ local loaded_settings = {}
+ local settings_file = io.open(settings_path)
+ if settings_file then
+ loaded_settings = lspc.json.decode(settings_file:read('*a'))
+ settings_file:close()
+ lspc:log('read settings from ' .. settings_path .. ': ' .. lspc.json.encode(loaded_settings))
+ end
+ return loaded_settings
+end
+
+--- Read the user's local settings file
+--
+-- Local user settings are stored at $XDG_CONFIG_HOME/vis-lspc/settings.json.
+-- @return the user's local settings table
+local function read_local_settings()
+ local xdg_conf_dir = os.getenv('XDG_CONFIG_HOME') or os.getenv('HOME') .. '/.config'
+ local settings_path = xdg_conf_dir .. '/vis-lspc/settings.json'
+ return read_settings(settings_path)
+end
+
+--- Get a specific section from a settings table
+-- @param tbl the settings table
+-- @param section the dot-separated section name
+-- @return the settings table for the requested section
+function settings.get_section(tbl, section)
+ local t = tbl
+ -- iterate the dot-separated section components
+ for k in section:gmatch('[^.]+') do
+ if not t then
+ break
+ end
+ t = t[k]
+ end
+
+ return t or {}
+end
+
+--- Merge settings along the scope of their file path
+--
+-- The settings are merged from the top most directory to the
+-- actual file path.
+-- @param path_specific_settings the path specific settings table
+-- @param project_root path to the project
+-- @return a table containing the effective settings
+local function merge_path_specific(path_specific_settings, project_root)
+ local merged_settings = {}
+ local path = ''
+ local file_path_components = util.split_path_into_components(project_root)
+
+ for _, comp in ipairs(file_path_components) do
+ path = path .. '/' .. comp
+ if path_specific_settings[path] then
+ merged_settings = util.table.merge(merged_settings, path_specific_settings[path])
+ end
+ end
+
+ return merged_settings
+end
+
+--- Load the client local settings.
+-- @param section the section of the settings
+-- @param scope the scope of the local settings
+-- @return a table containing the local settings
+function settings.local_settings(section, scope)
+ local local_settings = read_local_settings()
+ local local_ls_settings = local_settings[section]
+ if not local_ls_settings then
+ return {}
+ end
+ return merge_path_specific(local_ls_settings, scope)
+end
+
+--- Load the project local settings.
+-- @param section the section of the settings
+-- @param scope the scope of the project local settings
+-- @return a table containing the local settings
+function settings.project_local_settings(section, scope)
+ local merged_settings = {}
+ local settings_name = '.vis-lspc-settings.json'
+
+ while true do
+ local settings_dir = util.find_upwards(settings_name .. '\n', scope)
+ if not settings_dir or settings_dir == '/' then
+ return merged_settings
+ end
+
+ scope = settings_dir
+
+ local settings_path = settings_dir .. '/' .. settings_name
+ lspc:log('settings: found project specific settings at "' .. settings_path)
+ local ls_settings = read_settings(settings_path) or {}
+ if section then
+ ls_settings = ls_settings[section] or {}
+ end
+ -- The settings found later have less priority than the settings found earlier
+ merged_settings = util.table.merge(ls_settings, merged_settings)
+
+ end
+end
+
+--- Get the effective settings for a language server and an active file
+-- @param section the section name of settings
+-- @param scope the scope of where the settings should have effect
+-- @return a table containing the effective settings
+function settings.effective_settings(ls, section, scope)
+ lspc:log('get effective settings (' .. tostring(section) .. ', ' .. tostring(scope) .. ') for ' ..
+ ls.name)
+ local effective_settings = {}
+ if ls.conf.settings then
+ lspc:log('global settings ' .. lspc.json.encode(ls.conf.settings))
+ effective_settings = util.table.deep_copy(ls.conf.settings)
+ if section then
+ effective_settings = settings.get_section(effective_settings, section)
+ end
+ lspc:log('-> ' .. lspc.json.encode(effective_settings))
+ end
+
+ if scope then
+ local project_local_settings = settings.project_local_settings(section, scope)
+ lspc:log('project local settings ' .. lspc.json.encode(project_local_settings))
+ util.table.merge(effective_settings, project_local_settings)
+ lspc:log('-> ' .. lspc.json.encode(effective_settings))
+ end
+
+ local local_settings = settings.local_settings(section, scope)
+ lspc:log('local settings ' .. lspc.json.encode(local_settings))
+ util.table.merge(effective_settings, local_settings)
+
+ lspc:log('-> settings ' .. lspc.json.encode(effective_settings))
+ return effective_settings
+end
+
+vis:command_register('lspc-settings-reload', function(argv)
+ local ls, err = lspc.get_running_ls(vis.win, argv[1])
+ if err then
+ lspc:err(err)
+ return
+ end
+ assert(ls)
+
+ ls:send_default_settings()
+end, 'reload language server settings')
+
+vis:command_register('lspc-settings-show', function(argv)
+ local ls, err = lspc.get_running_ls(vis.win, argv[1])
+ if err then
+ lspc:err(err)
+ return
+ end
+ assert(ls)
+
+ local scope = ls.rootUri or vis.win.file and vis.win.file.path
+ local effective_ls_settings = settings.effective_settings(ls, nil, scope)
+ vis:message(lspc.json.encode(effective_ls_settings))
+end, 'show the language server settings')
+
+return settings
diff --git a/util.lua b/util.lua
index 3d10e95..265c272 100644
--- a/util.lua
+++ b/util.lua
@@ -3,7 +3,7 @@
-- @author Florian Fischer
-- @author git-bruh <prathamIN@proton.me>
-- @license GPL-3
--- @copyright 2024 Florian Fischer
+-- @copyright 2024-2025 Florian Fischer
-- @copyright 2024 git-bruh <prathamIN@proton.me>
local util = {}
@@ -316,4 +316,55 @@ util.visual_chars_in_line = function(win, line, nchars)
return line_len
end
+--- Utility table function
+util.table = {}
+
+--- Recursively copy a table and all its members.
+-- @param tbl the table to copy
+-- @return a deep copy of the table
+function util.table.deep_copy(tbl)
+ local cpy = {}
+ for k, v in pairs(tbl) do
+ if type(v) ~= 'table' then
+ cpy[k] = v
+ else
+ cpy[k] = util.table.deep_copy(v)
+ end
+ end
+
+ for i, v in ipairs(tbl) do
+ if type(v) ~= 'table' then
+ cpy[i] = v
+ else
+ cpy[i] = util.table.deep_copy(v)
+ end
+ end
+ return cpy
+end
+
+--- Recursively merge two tables.
+-- This method modifies the weak table, if this is not intended make sure you
+-- use a deep copy of the weak table.
+-- Named members of the strong table override members of the weak one.
+-- Numbered members get inserted into the weak table.
+-- @param weak table which's members are potentially overridden
+-- @param strong table which's members override ones from the weak table
+-- @return the merged table
+function util.table.merge(weak, strong)
+ for k, v in pairs(strong) do
+ if type(v) ~= 'table' then
+ if tonumber(k) then -- append numeric members
+ table.insert(weak, v)
+ else -- potentially override the weak member
+ weak[k] = v
+ end
+
+ else
+ weak[k] = util.table.merge(weak[k] or {}, v)
+ end
+ end
+
+ return weak
+end
+
return util
diff --git a/util_test.lua b/util_test.lua
index a0f4218..63393b1 100755
--- a/util_test.lua
+++ b/util_test.lua
@@ -30,4 +30,47 @@ function test_visual_chars_in_line() -- luacheck: ignore 111
lunatest.assert_equal(util.visual_chars_in_line(win, s, #s), 6)
end
+function test_table_deep_copy() -- luacheck: ignore 111
+ local t = {1, 2, 3, foo = {4, 5, bar = 'bar'}}
+ local cpy = util.table.deep_copy(t)
+
+ lunatest.assert_table(cpy)
+ lunatest.assert_len(3, cpy)
+ lunatest.assert_table(cpy.foo)
+ lunatest.assert_len(2, cpy.foo)
+ for i, v in ipairs(t) do
+ lunatest.assert_equal(v, cpy[i])
+ end
+
+ t[2] = 12
+ lunatest.assert_not_equal(t[2], cpy[2])
+ t.foo[2] = 13
+ lunatest.assert_not_equal(t.foo[2], cpy.foo[2])
+end
+
+function test_table_merge() -- luacheck: ignore 111
+ local t = {1, 2, foo = {bar = {nose = 'nose'}}}
+ local t2 = {3, foo = {4, 5, bar = {bar = 'bar'}}}
+ lunatest.assert_table(t)
+ lunatest.assert_table(t2)
+
+ util.table.merge(t, t2)
+
+ lunatest.assert_table(t)
+ lunatest.assert_len(3, t)
+
+ lunatest.assert_equal(1, t[1])
+ lunatest.assert_equal(2, t[2])
+ lunatest.assert_equal(3, t[3])
+
+ lunatest.assert_table(t.foo)
+ lunatest.assert_len(2, t.foo)
+
+ lunatest.assert_equal(t[3], t2[1])
+ lunatest.assert_equal(t[2], 2)
+
+ lunatest.assert_equal(t.foo.bar.bar, 'bar')
+ lunatest.assert_equal(t.foo.bar.nose, 'nose')
+end
+
lunatest.run()