From cb27da578ca75e651c033381d9d830f1d0db6e2f Mon Sep 17 00:00:00 2001 From: Florian Fischer Date: Sun, 26 Jan 2025 22:22:20 +0100 Subject: add table helper utility functions --- util.lua | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ util_test.lua | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/util.lua b/util.lua index 3d10e95..6aa4b92 100644 --- a/util.lua +++ b/util.lua @@ -316,4 +316,60 @@ 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. +-- The members of the strong table override members of the weak one. +-- @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) + -- merge named members + for k, v in pairs(strong) do + -- potentially override the weak member + if type(v) ~= 'table' then + weak[k] = v + else + weak[k] = util.table.merge(weak[k] or {}, v) + end + end + + -- merge numbered members + for i, v in ipairs(strong) do + -- potentially override the weak member + if type(v) ~= 'table' then + weak[i] = v + else + weak[i] = util.table.merge(weak[i] or {}, v) + end + end + return weak +end + return util diff --git a/util_test.lua b/util_test.lua index a0f4218..661b9f5 100755 --- a/util_test.lua +++ b/util_test.lua @@ -30,4 +30,42 @@ 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(2, t) + lunatest.assert_table(t.foo) + lunatest.assert_len(2, t.foo) + + lunatest.assert_equal(t[1], 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() -- cgit v1.2.3 From 36c7ed2b678f352c9938da3188ad3f689cd1a7bb Mon Sep 17 00:00:00 2001 From: Florian Fischer Date: Sun, 26 Jan 2025 22:26:53 +0100 Subject: add language server configuration to its object table --- init.lua | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/init.lua b/init.lua index a47ad22..865d35a 100644 --- a/init.lua +++ b/init.lua @@ -1280,7 +1280,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 +1313,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 +1327,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 +1636,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 = { -- cgit v1.2.3 From 4c3bce82fad31b67d0d8fe00c17a0ea66c4ad9d1 Mon Sep 17 00:00:00 2001 From: Florian Fischer Date: Mon, 27 Jan 2025 09:40:44 +0100 Subject: implement more sophisticated settings --- README.md | 27 ++++++++ init.lua | 65 +++++++++++++------ settings.lua | 202 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 275 insertions(+), 19 deletions(-) create mode 100644 settings.lua 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 865d35a..b433b12 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 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,20 +1041,15 @@ 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) - end + local scope = item.scopeUri and uri_to_path(item.scopeUri) + local effective_settings = settings.effective_settings(ls, item.section, scope) + table.insert(results, effective_settings) end response.result = results end @@ -1161,8 +1170,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 +1199,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 diff --git a/settings.lua b/settings.lua new file mode 100644 index 0000000..0ce1185 --- /dev/null +++ b/settings.lua @@ -0,0 +1,202 @@ +--- 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)[section] or {} + -- 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(effective_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 -- cgit v1.2.3 From 6230265151e1ecf95488f5c9eda1c48c48014d8e Mon Sep 17 00:00:00 2001 From: Florian Fischer Date: Thu, 20 Mar 2025 15:21:33 -0300 Subject: do not apply unspecified sections to project local settings --- settings.lua | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/settings.lua b/settings.lua index 0ce1185..289bb96 100644 --- a/settings.lua +++ b/settings.lua @@ -137,9 +137,13 @@ function settings.project_local_settings(section, scope) local settings_path = settings_dir .. '/' .. settings_name lspc:log('settings: found project specific settings at "' .. settings_path) - local ls_settings = read_settings(settings_path)[section] or {} + 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 -- cgit v1.2.3 From 99093835a557cd04de0c37206debdcd345801199 Mon Sep 17 00:00:00 2001 From: Florian Fischer Date: Thu, 20 Mar 2025 15:24:32 -0300 Subject: append numbered members when merging Overriding numbered members is not intuitive. Merging {1} and {2} should return {1,2} not {2}. --- util.lua | 23 +++++++++-------------- util_test.lua | 9 +++++++-- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/util.lua b/util.lua index 6aa4b92..265c272 100644 --- a/util.lua +++ b/util.lua @@ -3,7 +3,7 @@ -- @author Florian Fischer -- @author git-bruh -- @license GPL-3 --- @copyright 2024 Florian Fischer +-- @copyright 2024-2025 Florian Fischer -- @copyright 2024 git-bruh local util = {} @@ -345,30 +345,25 @@ 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. --- The members of the strong table override members of the weak one. +-- 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) - -- merge named members for k, v in pairs(strong) do - -- potentially override the weak member if type(v) ~= 'table' then - weak[k] = v + 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 - -- merge numbered members - for i, v in ipairs(strong) do - -- potentially override the weak member - if type(v) ~= 'table' then - weak[i] = v - else - weak[i] = util.table.merge(weak[i] or {}, v) - end - end return weak end diff --git a/util_test.lua b/util_test.lua index 661b9f5..63393b1 100755 --- a/util_test.lua +++ b/util_test.lua @@ -57,11 +57,16 @@ function test_table_merge() -- luacheck: ignore 111 util.table.merge(t, t2) lunatest.assert_table(t) - lunatest.assert_len(2, 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[1], t2[1]) + lunatest.assert_equal(t[3], t2[1]) lunatest.assert_equal(t[2], 2) lunatest.assert_equal(t.foo.bar.bar, 'bar') -- cgit v1.2.3 From db4ffde924c053357d9d34134a337391088b5b3b Mon Sep 17 00:00:00 2001 From: Florian Fischer Date: Thu, 20 Mar 2025 15:28:12 -0300 Subject: log the actual global settings --- settings.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/settings.lua b/settings.lua index 289bb96..ed753d4 100644 --- a/settings.lua +++ b/settings.lua @@ -156,7 +156,7 @@ function settings.effective_settings(ls, section, scope) ls.name) local effective_settings = {} if ls.conf.settings then - lspc:log('global settings ' .. lspc.json.encode(effective_settings)) + 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) -- cgit v1.2.3 From 22dee48bad475064616dabf8122c2d7bc1444f72 Mon Sep 17 00:00:00 2001 From: Florian Fischer Date: Wed, 13 Aug 2025 14:03:26 +0200 Subject: settings: do not send empty settings arrays to the server LSP requires that the result array of a workspace/configure call must include null if the client can not provide any configuration. --- init.lua | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/init.lua b/init.lua index b433b12..9a40bf9 100644 --- a/init.lua +++ b/init.lua @@ -967,7 +967,7 @@ 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 effective_ls_settings then + if next(effective_ls_settings) then self:send_notification('workspace/didChangeConfiguration', { settings = effective_ls_settings, }) @@ -1049,7 +1049,12 @@ local function lspc_handle_workspace_configuration_call(ls, params, response) for _, item in ipairs(params.items) do local scope = item.scopeUri and uri_to_path(item.scopeUri) local effective_settings = settings.effective_settings(ls, item.section, scope) - table.insert(results, effective_settings) + -- 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 end -- cgit v1.2.3