diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bc8b466..d0b95b0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- [Experimental] Load rust-analyzer settings from `.vscode/settings.json`. + Can be enabled by setting `vim.g.rustaceanvim.server.load_vscode_settings` + to `true` [[#286](https://github.com/mrcjkb/rustaceanvim/issues/286)]. + ## [4.13.0] - 2024-03-15 ### Added @@ -14,6 +22,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The `server.settings` function can now take a `default_settings` tabe to be merged. +### Fixed + +- Loading settings from `rust-analyzer.json`: + Potential for duplicate lua config keys if json keys are of the format: + `"rust-analyzer.foo.bar"` + ## [4.12.2] - 2024-03-11 ### Fixed diff --git a/doc/rustaceanvim.txt b/doc/rustaceanvim.txt index b01948e6..9b3461a1 100644 --- a/doc/rustaceanvim.txt +++ b/doc/rustaceanvim.txt @@ -209,11 +209,12 @@ RustcOpts *RustcOpts* RustaceanLspClientOpts *RustaceanLspClientOpts* Fields: ~ - {auto_attach?} (boolean|fun(bufnr:integer):boolean) Whether to automatically attach the LSP client. Defaults to `true` if the `rust-analyzer` executable is found. - {cmd?} (string[]|fun():string[]) Command and arguments for starting rust-analyzer - {settings?} (table|fun(project_root:string|nil,default_settings:table):table) Setting passed to rust-analyzer. Defaults to a function that looks for a `rust-analyzer.json` file or returns an empty table. See https://rust-analyzer.github.io/manual.html#configuration. - {standalone?} (boolean) Standalone file support (enabled by default). Disabling it may improve rust-analyzer's startup time. - {logfile?} (string) The path to the rust-analyzer log file. + {auto_attach?} (boolean|fun(bufnr:integer):boolean) Whether to automatically attach the LSP client. Defaults to `true` if the `rust-analyzer` executable is found. + {cmd?} (string[]|fun():string[]) Command and arguments for starting rust-analyzer + {settings?} (table|fun(project_root:string|nil,default_settings:table):table) Setting passed to rust-analyzer. Defaults to a function that looks for a `rust-analyzer.json` file or returns an empty table. See https://rust-analyzer.github.io/manual.html#configuration. + {standalone?} (boolean) Standalone file support (enabled by default). Disabling it may improve rust-analyzer's startup time. + {logfile?} (string) The path to the rust-analyzer log file. + {load_vscode_settings?} (boolean) Whether to search (upward from the buffer) for rust-analyzer settings in .vscode/settings json. If found, loaded settings will override configured options. Default: false RustaceanDapOpts *RustaceanDapOpts* diff --git a/lua/rustaceanvim/config/init.lua b/lua/rustaceanvim/config/init.lua index 671f76e0..53c0eb20 100644 --- a/lua/rustaceanvim/config/init.lua +++ b/lua/rustaceanvim/config/init.lua @@ -105,6 +105,7 @@ vim.g.rustaceanvim = vim.g.rustaceanvim ---@field settings? table | fun(project_root:string|nil, default_settings: table):table Setting passed to rust-analyzer. Defaults to a function that looks for a `rust-analyzer.json` file or returns an empty table. See https://rust-analyzer.github.io/manual.html#configuration. ---@field standalone? boolean Standalone file support (enabled by default). Disabling it may improve rust-analyzer's startup time. ---@field logfile? string The path to the rust-analyzer log file. +---@field load_vscode_settings? boolean Whether to search (upward from the buffer) for rust-analyzer settings in .vscode/settings json. If found, loaded settings will override configured options. Default: false ---@class RustaceanDapOpts --- @field autoload_configurations boolean Whether to autoload nvim-dap configurations when rust-analyzer has attached? Default: `true`. diff --git a/lua/rustaceanvim/config/internal.lua b/lua/rustaceanvim/config/internal.lua index a806ab0c..a51b9129 100644 --- a/lua/rustaceanvim/config/internal.lua +++ b/lua/rustaceanvim/config/internal.lua @@ -271,6 +271,8 @@ local RustaceanDefaultConfig = { --- @type table ['rust-analyzer'] = {}, }, + ---@type boolean Whether to search (upward from the buffer) for rust-analyzer settings in .vscode/settings json. + load_vscode_settings = false, }, --- debugging stuff diff --git a/lua/rustaceanvim/config/json.lua b/lua/rustaceanvim/config/json.lua new file mode 100644 index 00000000..351e409f --- /dev/null +++ b/lua/rustaceanvim/config/json.lua @@ -0,0 +1,50 @@ +local M = {} + +local function tbl_set(tbl, keys, value) + local next = table.remove(keys, 1) + if #keys > 0 then + tbl[next] = tbl[next] or {} + tbl_set(tbl[next], keys, value) + else + tbl[next] = value + end +end + +---@param tbl table +---@param json_key string e.g. "rust-analyzer.check.overrideCommand" +---@param json_value unknown +local function override_tbl_values(tbl, json_key, json_value) + local keys = vim.split(json_key, '%.') + tbl_set(tbl, keys, json_value) +end + +---@param json_content string +---@return table +function M.silent_decode(json_content) + local ok, json_tbl = pcall(vim.json.decode, json_content) + if not ok or type(json_tbl) ~= 'table' then + return {} + end + return json_tbl +end + +---@param tbl table +---@param json_tbl { [string]: unknown } +---@param key_predicate? fun(string): boolean +function M.override_with_json_keys(tbl, json_tbl, key_predicate) + for json_key, value in pairs(json_tbl) do + if not key_predicate or key_predicate(json_key) then + override_tbl_values(tbl, json_key, value) + end + end +end + +---@param tbl table +---@param json_tbl { [string]: unknown } +function M.override_with_rust_analyzer_json_keys(tbl, json_tbl) + M.override_with_json_keys(tbl, json_tbl, function(key) + return vim.startswith(key, 'rust-analyzer') + end) +end + +return M diff --git a/lua/rustaceanvim/config/server.lua b/lua/rustaceanvim/config/server.lua index 5cc92877..ee9e84c0 100644 --- a/lua/rustaceanvim/config/server.lua +++ b/lua/rustaceanvim/config/server.lua @@ -2,19 +2,6 @@ local server = {} ----Read the content of a file ----@param filename string ----@return string|nil content -local function read_file(filename) - local content - local f = io.open(filename, 'r') - if f then - content = f:read('*a') - f:close() - end - return content -end - ---@class LoadRASettingsOpts ---@field settings_file_pattern string|nil File name or pattern to search for. Defaults to 'rust-analyzer.json' ---@field default_settings table|nil Default settings to merge the loaded settings into @@ -28,6 +15,7 @@ end function server.load_rust_analyzer_settings(project_root, opts) local config = require('rustaceanvim.config.internal') local compat = require('rustaceanvim.compat') + local os = require('rustaceanvim.os') local default_opts = { settings_file_pattern = 'rust-analyzer.json' } opts = vim.tbl_deep_extend('force', {}, default_opts, opts or {}) @@ -50,20 +38,27 @@ function server.load_rust_analyzer_settings(project_root, opts) return default_settings end local config_json = results[1] - local content = read_file(config_json) - local success, rust_analyzer_settings = pcall(vim.json.decode, content) - if not success or not rust_analyzer_settings then - local msg = 'Could not decode ' .. config_json .. '. Falling back to default settings.' - vim.notify('rustaceanvim: ' .. msg, vim.log.levels.ERROR) + local content = os.read_file(config_json) + if not content then + vim.notify('Could not read ' .. config_json, vim.log.levels.WARNING) return default_settings end + local json = require('rustaceanvim.config.json') + local rust_analyzer_settings = json.silent_decode(content) local ra_key = 'rust-analyzer' - if rust_analyzer_settings[ra_key] then + local has_ra_key = true + for key, _ in pairs(rust_analyzer_settings) do + if key:find(ra_key) ~= nil then + has_ra_key = true + break + end + end + if has_ra_key then -- Settings json with "rust-analyzer" key - default_settings[ra_key] = rust_analyzer_settings[ra_key] + json.override_with_rust_analyzer_json_keys(default_settings, rust_analyzer_settings) else -- "rust-analyzer" settings are top level - default_settings[ra_key] = rust_analyzer_settings + json.override_with_json_keys(default_settings, rust_analyzer_settings) end return default_settings end diff --git a/lua/rustaceanvim/lsp.lua b/lua/rustaceanvim/lsp.lua index 9457436a..9417421f 100644 --- a/lua/rustaceanvim/lsp.lua +++ b/lua/rustaceanvim/lsp.lua @@ -35,6 +35,25 @@ local function is_in_workspace(client, root_dir) return false end +---@Searches upward for a .vscode/settings.json that contains rust-analyzer +--- settings and applies them. +---@param bufname string +---@return table server_settings or an empty table if no settings were found +local function find_vscode_settings(bufname) + local settings = {} + local found_dirs = vim.fs.find({ '.vscode' }, { upward = true, path = vim.fs.dirname(bufname), type = 'directory' }) + if vim.tbl_isempty(found_dirs) then + return settings + end + local vscode_dir = found_dirs[1] + local results = vim.fn.glob(compat.joinpath(vscode_dir, 'settings.json'), true, true) + if vim.tbl_isempty(results) then + return settings + end + local content = os.read_file(results[1]) + return content and require('rustaceanvim.config.json').silent_decode(content) or {} +end + ---@class LspStartConfig: RustaceanLspClientConfig ---@field root_dir string | nil ---@field init_options? table @@ -53,23 +72,30 @@ end ---@return integer|nil client_id The LSP client ID M.start = function(bufnr) bufnr = bufnr or vim.api.nvim_get_current_buf() + local bufname = vim.api.nvim_buf_get_name(bufnr) local client_config = config.server ---@type LspStartConfig local lsp_start_config = vim.tbl_deep_extend('force', {}, client_config) - local root_dir = cargo.get_root_dir(vim.api.nvim_buf_get_name(bufnr)) + local root_dir = cargo.get_root_dir(bufname) root_dir = root_dir and os.normalize_path_on_windows(root_dir) lsp_start_config.root_dir = root_dir if not root_dir then --- No project root found. Start in detached/standalone mode. - lsp_start_config.init_options = { detachedFiles = { vim.api.nvim_buf_get_name(bufnr) } } + lsp_start_config.init_options = { detachedFiles = { bufname } } end local settings = client_config.settings local evaluated_settings = type(settings) == 'function' and settings(root_dir, client_config.default_settings) or settings + ---@cast evaluated_settings table lsp_start_config.settings = evaluated_settings + if config.server.load_vscode_settings then + local json_settings = find_vscode_settings(bufname) + require('rustaceanvim.config.json').override_with_rust_analyzer_json_keys(lsp_start_config.settings, json_settings) + end + -- Check if a client is already running and add the workspace folder if necessary. for _, client in pairs(rust_analyzer.get_active_rustaceanvim_clients()) do if root_dir and not is_in_workspace(client, root_dir) then diff --git a/lua/rustaceanvim/os.lua b/lua/rustaceanvim/os.lua index 508c2c4e..a84a3a66 100644 --- a/lua/rustaceanvim/os.lua +++ b/lua/rustaceanvim/os.lua @@ -60,4 +60,17 @@ function os.is_valid_file_path(path) return vim.startswith(normalized_path, '/') end +---Read the content of a file +---@param filename string +---@return string|nil content +function os.read_file(filename) + local content + local f = io.open(filename, 'r') + if f then + content = f:read('*a') + f:close() + end + return content +end + return os diff --git a/spec/config_server_spec.lua b/spec/config_server_spec.lua new file mode 100644 index 00000000..0258d56e --- /dev/null +++ b/spec/config_server_spec.lua @@ -0,0 +1,18 @@ +local config = require('rustaceanvim.config.server') +local compat = require('rustaceanvim.compat') +describe('Decode rust-analyzer settings from json', function() + local function expect_correct_loading(json_path) + local settings = config.load_rust_analyzer_settings(json_path) + assert.True(settings['rust-analyzer'].cargo.targetDir) + assert.same({ 'leptosfmt', '--stdin', '--rustfmt' }, settings['rust-analyzer'].rustfmt.overrideCommand) + assert.True(settings['rust-analyzer'].checkOnSave) + end + it('Fixture: rust-analyzer key', function() + local json_path = compat.joinpath(vim.fn.getcwd(), 'spec', 'fixtures', 'rust-analyzer-json') + expect_correct_loading(json_path) + end) + it('Fixture: top-level settings', function() + local json_path = compat.joinpath(vim.fn.getcwd(), 'spec', 'fixtures', 'rust-analyzer-json-top-level') + expect_correct_loading(json_path) + end) +end) diff --git a/spec/fixtures/rust-analyzer-json-top-level/rust-analyzer.json b/spec/fixtures/rust-analyzer-json-top-level/rust-analyzer.json new file mode 100644 index 00000000..7c661c78 --- /dev/null +++ b/spec/fixtures/rust-analyzer-json-top-level/rust-analyzer.json @@ -0,0 +1,5 @@ +{ + "cargo.targetDir": true, + "rustfmt.overrideCommand": ["leptosfmt", "--stdin", "--rustfmt"], + "checkOnSave": true +} diff --git a/spec/fixtures/rust-analyzer-json/rust-analyzer.json b/spec/fixtures/rust-analyzer-json/rust-analyzer.json new file mode 100644 index 00000000..1706e8e6 --- /dev/null +++ b/spec/fixtures/rust-analyzer-json/rust-analyzer.json @@ -0,0 +1,5 @@ +{ + "rust-analyzer.cargo.targetDir": true, + "rust-analyzer.rustfmt.overrideCommand": ["leptosfmt", "--stdin", "--rustfmt"], + "rust-analyzer.checkOnSave": true +} diff --git a/spec/json_spec.lua b/spec/json_spec.lua new file mode 100644 index 00000000..838d75ae --- /dev/null +++ b/spec/json_spec.lua @@ -0,0 +1,45 @@ +local json = require('rustaceanvim.config.json') +describe('Decode rust-analyzer settings from json', function() + it('can extract rust-analyzer key from index', function() + local json_content = [[ +{ + "rust-analyzer.check.overrideCommand": [ + "cargo", + "check", + "-p", + "service_b", + "--message-format=json" + ], + "rust-analyzer.foo.enable": true, + "rust-analyzer.foo.bar.enable": true, + "rust-analyzer.foo.bar.baz.bat": "something deeply nested", + "some-other-key.foo.bar.baz.bat": "should not be included" +} +]] + local tbl = {} + local json_tbl = json.silent_decode(json_content) + json.override_with_rust_analyzer_json_keys(tbl, json_tbl) + assert.same({ + ['rust-analyzer'] = { + check = { + overrideCommand = { + 'cargo', + 'check', + '-p', + 'service_b', + '--message-format=json', + }, + }, + foo = { + enable = true, + bar = { + enable = true, + baz = { + bat = 'something deeply nested', + }, + }, + }, + }, + }, tbl) + end) +end)