diff --git a/.gitignore b/.gitignore index f058235..ad31065 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ !.yarn/versions node_modules/ .tests/ +.direnv diff --git a/README.md b/README.md index 6dc2059..687bd6e 100644 --- a/README.md +++ b/README.md @@ -56,11 +56,18 @@ local config = { -- See the currently supported files in https://github.com/davidmh/cspell.nvim/blob/main/lua/cspell/helpers.lua config_file_preferred_name = 'cspell.json', + -- A list of directories that contain additional cspell.json config files or + -- support the creation of a new config file from a code action + -- + -- looks for a cspell config in the ~/.config/ directory, or creates a file in the directory + -- using 'config_file_preferred_name' when a code action for one of the locations is selected + cspell_config_dirs = { "~/.config/" } + --- A way to define your own logic to find the CSpell configuration file. - ---@params cwd The same current working directory defined in the source, + ---@params directory The same directory defined in the source, -- defaulting to vim.loop.cwd() ---@return string|nil The path of the json file - find_json = function(cwd) + find_json = function(directory) end, -- Will find and read the cspell config file synchronously, as soon as the diff --git a/custom-dictionary.txt b/custom-dictionary.txt index e693258..66c4648 100644 --- a/custom-dictionary.txt +++ b/custom-dictionary.txt @@ -10,3 +10,8 @@ luassert realpath neotest Neovim +stdpath +joinpath +fnamemodify +itable +fnameescape diff --git a/lua/cspell/code_actions/init.lua b/lua/cspell/code_actions/init.lua index 0816c5b..bb4629e 100644 --- a/lua/cspell/code_actions/init.lua +++ b/lua/cspell/code_actions/init.lua @@ -23,15 +23,16 @@ end ---@param code_action_config CSpellSourceConfig ---@param params GeneratorParams -local get_config_info = function(code_action_config, params) +---@param cspell_json_path string +local get_config_info = function(code_action_config, params, cspell_json_path) -- In theory, the call to async_get_config_info in the diagnostics source -- should already have been loaded, that's why we're defaulting reading the -- config synchronously here. if code_action_config.read_config_synchronously then - return h.sync_get_config_info(params) + return h.sync_get_config_info(params, cspell_json_path) end - return h.async_get_config_info(params) + return h.async_get_config_info(params, cspell_json_path) end return make_builtin({ @@ -58,7 +59,22 @@ return make_builtin({ ---@type CSpellSourceConfig local code_action_config = vim.tbl_extend("force", { read_config_synchronously = true }, params:get_config()) - local cspell = get_config_info(code_action_config, params) + + ---@type table + local cspell_config_paths = {} + + local cspell_config_directories = code_action_config.cspell_config_dirs or {} + table.insert(cspell_config_directories, params.cwd) + + for _, cspell_config_directory in pairs(cspell_config_directories) do + local cspell_config_path = h.get_config_path(params, cspell_config_directory) + if cspell_config_path == nil then + cspell_config_path = h.generate_cspell_config_path(params, cspell_config_directory) + end + cspell_config_paths[cspell_config_directory] = cspell_config_path + end + + local default_cspell_config = get_config_info(code_action_config, params, cspell_config_paths[params.cwd]) ---@type table local actions = {} @@ -94,7 +110,11 @@ return make_builtin({ vim.log.levels.INFO, { title = "cspell.nvim" } ) - on_success(cspell and cspell.path, params, "use_suggestion") + on_success( + default_cspell_config and default_cspell_config.path, + params, + "use_suggestion" + ) end if on_use_suggestion then @@ -102,7 +122,7 @@ return make_builtin({ local payload = { misspelled_word = diagnostic.user_data.misspelled, suggestion = suggestion, - cspell_config_path = cspell and cspell.path, + cspell_config_path = default_cspell_config and default_cspell_config.path, generator_params = params, } on_use_suggestion(payload) @@ -112,34 +132,40 @@ return make_builtin({ end local word = h.get_word(diagnostic) - - -- add word to "words" in cspell.json - table.insert( - actions, - make_add_to_json({ - diagnostic = diagnostic, - word = word, - params = params, - }) - ) - - if cspell == nil then - break + local dictionary_cspell_configs = {} + + for _, cspell_config_path in pairs(cspell_config_paths) do + -- add word to "words" in cspell.json + table.insert( + actions, + make_add_to_json({ + diagnostic = diagnostic, + word = word, + params = params, + cspell_config_path = cspell_config_path, + }) + ) + local cspell_config = get_config_info(code_action_config, params, cspell_config_path) + if cspell_config and cspell_config.config.dictionaryDefinitions then + dictionary_cspell_configs[cspell_config_path] = cspell_config + end end -- add word to a custom dictionary - for _, dictionary in ipairs(cspell.config.dictionaryDefinitions or {}) do - if dictionary ~= nil and dictionary.addWords then - table.insert( - actions, - make_add_to_dictionary_action({ - diagnostic = diagnostic, - word = word, - params = params, - cspell = cspell, - dictionary = dictionary, - }) - ) + for _, cspell_config in pairs(dictionary_cspell_configs) do + for _, dictionary in ipairs(cspell_config.config.dictionaryDefinitions) do + if dictionary ~= nil and dictionary.addWords then + table.insert( + actions, + make_add_to_dictionary_action({ + diagnostic = diagnostic, + word = word, + params = params, + cspell = cspell_config, + dictionary = dictionary, + }) + ) + end end end end diff --git a/lua/cspell/code_actions/make_add_to_dictionary_action.lua b/lua/cspell/code_actions/make_add_to_dictionary_action.lua index eff5c67..075f4d5 100644 --- a/lua/cspell/code_actions/make_add_to_dictionary_action.lua +++ b/lua/cspell/code_actions/make_add_to_dictionary_action.lua @@ -21,9 +21,14 @@ return function(opts) -- user_data. And only use the word from the range to trigger a new diagnostic. -- See: https://github.com/jose-elias-alvarez/null-ls.nvim/issues/1630 local misspelled_word = opts.diagnostic.user_data.misspelled + local dictionary = opts.dictionary.name + local shortened_path = h.shorten_path(opts.cspell.path) return { - title = 'Add "' .. misspelled_word .. '" to dictionary "' .. opts.dictionary.name .. '"', + title = h.format( + 'Add "${word}" to dictionary "${dictionary}" from "${config_path}"', + { word = misspelled_word, dictionary = dictionary, config_path = shortened_path } + ), action = function() if opts.dictionary == nil then return diff --git a/lua/cspell/code_actions/make_add_to_json.lua b/lua/cspell/code_actions/make_add_to_json.lua index 490733a..4381dbf 100644 --- a/lua/cspell/code_actions/make_add_to_json.lua +++ b/lua/cspell/code_actions/make_add_to_json.lua @@ -4,6 +4,7 @@ local h = require("cspell.helpers") ---@class AddToJSONAction ---@field diagnostic Diagnostic ---@field word string +---@field cspell_config_path string ---@field params GeneratorParams ---@param opts AddToJSONAction @@ -17,22 +18,28 @@ return function(opts) local misspelled_word = opts.diagnostic.user_data.misspelled return { - title = 'Add "' .. misspelled_word .. '" to cspell json file', + title = h.format( + 'Add "${word}" to "${config_path}"', + { word = misspelled_word, config_path = h.shorten_path(opts.cspell_config_path) } + ), + action = function() + local cspell_config_path = opts.cspell_config_path -- get a fresh config when the action is performed, which can be much later than when the action was generated - local cspell = h.async_get_config_info(opts.params) - local config_path = h.get_config_path(opts.params) - if not cspell and config_path and Path:new(config_path):exists() then + local cspell = h.async_get_config_info(opts.params, cspell_config_path) + local path_exists = Path:new(cspell_config_path):exists() + if not cspell and path_exists then h.cache_word_for_json(misspelled_word) return end - cspell = cspell or h.create_cspell_json(opts.params) + + cspell = cspell or h.create_default_cspell_json(opts.params, cspell_config_path) if not cspell.config.words then cspell.config.words = {} end - h.add_words_to_json(opts.params, { misspelled_word }) + h.add_words_to_json(opts.params, { misspelled_word }, cspell_config_path) -- replace word in buffer to trigger cspell to update diagnostics h.set_word(opts.diagnostic, opts.word) diff --git a/lua/cspell/diagnostics/init.lua b/lua/cspell/diagnostics/init.lua index ab89eae..f6e072f 100644 --- a/lua/cspell/diagnostics/init.lua +++ b/lua/cspell/diagnostics/init.lua @@ -19,19 +19,33 @@ return h.make_builtin({ command = "cspell", ---@param params GeneratorParams args = function(params) - params.cwd = params.cwd or vim.loop.cwd() - local cspell_args = { "lint", "--language-id", params.ft, "stdin://" .. params.bufname, } + params.cwd = params.cwd or vim.loop.cwd() + + ---@type CSpellSourceConfig + local diagnostics_config = params and params:get_config() or {} - local config_path = helpers.get_config_path(params) - if config_path then - cspell_args = vim.list_extend({ "-c", config_path }, cspell_args) + ---@type table + local cspell_config_paths = {} + + local cspell_config_directories = diagnostics_config.cspell_config_dirs or {} + table.insert(cspell_config_directories, params.cwd) + + for _, cspell_config_directory in pairs(cspell_config_directories) do + local cspell_config_path = helpers.get_config_path(params, cspell_config_directory) + if cspell_config_path == nil then + cspell_config_path = helpers.generate_cspell_config_path(params, cspell_config_directory) + end + cspell_config_paths[cspell_config_directory] = cspell_config_path end + local merged_config = helpers.create_merged_cspell_json(params, cspell_config_paths) + + cspell_args = vim.list_extend({ "-c", merged_config.path }, cspell_args) local code_action_source = require("null-ls.sources").get({ name = "cspell", @@ -43,11 +57,10 @@ return h.make_builtin({ cspell_args = vim.list_extend({ "--show-suggestions" }, cspell_args) local code_action_config = code_action_source.config or {} - local diagnostics_config = params and params:get_config() or {} if helpers.matching_configs(code_action_config, diagnostics_config) then -- warm up the config cache so we have the config ready by the time we call the code action - helpers.async_get_config_info(params) + helpers.async_get_config_info(params, cspell_config_paths[params.cwd]) elseif needs_warning then needs_warning = false vim.notify( diff --git a/lua/cspell/helpers.lua b/lua/cspell/helpers.lua index a37bf04..87e71ca 100644 --- a/lua/cspell/helpers.lua +++ b/lua/cspell/helpers.lua @@ -1,5 +1,5 @@ local Path = require("plenary.path") -local uv = vim.loop +local logger = require("null-ls.logger") local M = {} local CACHED_JSON_WORDS = {} @@ -11,29 +11,70 @@ local CSPELL_CONFIG_FILES = { ".cSpell.json", ".cspell.config.json", } - ---@type table -local CONFIG_INFO_BY_CWD = {} -local PATH_BY_CWD = {} +local CONFIG_INFO_BY_PATH = {} +---@type table +local PATH_BY_DIRECTORY = {} ---- create a bare minimum cspell.json file ----@param params GeneratorParams ----@return CSpellConfigInfo -M.create_cspell_json = function(params) +local create_cspell_json = function(params, cspell_json, cspell_json_file_path) ---@type CSpellSourceConfig local code_action_config = params:get_config() - local config_file_preferred_name = code_action_config.config_file_preferred_name or "cspell.json" local encode_json = code_action_config.encode_json or vim.json.encode + local cspell_json_str = encode_json(cspell_json) - if not vim.tbl_contains(CSPELL_CONFIG_FILES, config_file_preferred_name) then - vim.notify( - "Invalid config_file_preferred_name for cspell json file: " - .. config_file_preferred_name - .. '. The name "cspell.json" will be used instead', - vim.log.levels.WARN, - { title = "cspell.nvim" } - ) - config_file_preferred_name = "cspell.json" + local cspell_json_directory_path = vim.fs.dirname(cspell_json_file_path) + Path:new(cspell_json_directory_path):mkdir({ parents = true }) + Path:new(cspell_json_file_path):write(cspell_json_str, "w") + + local debug_message = + M.format('Created a new cspell.json file at "${file_path}"', { file_path = cspell_json_file_path }) + logger:debug(debug_message) + + local info = { + config = cspell_json, + path = cspell_json_file_path, + } + + CONFIG_INFO_BY_PATH[cspell_json_file_path] = info + return info +end + +local set_create = function(itable) + local set = {} + for _, value in pairs(itable) do + set[value] = true + end + return set +end + +local set_compare = function(expected_values, new_values) + for key, _ in pairs(expected_values) do + if new_values[key] == nil then + return false + end + end + return true +end + +--- create a merged cspell.json file that imports all cspell configs defined in cspell_config_dirs +---@param params GeneratorParams +---@param cspell_config_mapping table +---@return CSpellConfigInfo +M.create_merged_cspell_json = function(params, cspell_config_mapping) + local vim_cache = vim.fn.stdpath("cache") + local plugin_name = "cspell.nvim" + + local merged_config_key = Path:new(params.cwd):joinpath("cspell.json"):absolute():gsub("/", "%%") + local merged_config_path = Path:new(vim_cache):joinpath(plugin_name):joinpath(merged_config_key):absolute() + + local cspell_config_paths = {} + + if CONFIG_INFO_BY_PATH[merged_config_path] ~= nil then + return CONFIG_INFO_BY_PATH[merged_config_path] + end + + for _, cspell_config_path in pairs(cspell_config_mapping) do + table.insert(cspell_config_paths, cspell_config_path) end local cspell_json = { @@ -41,26 +82,36 @@ M.create_cspell_json = function(params) language = "en", words = {}, flagWords = {}, + import = cspell_config_paths, } - local cspell_json_str = encode_json(cspell_json) - local cspell_json_file_path = require("null-ls.utils").path.join(params.cwd, config_file_preferred_name) + local existing_config = M.get_cspell_config(params, merged_config_path) - Path:new(cspell_json_file_path):write(cspell_json_str, "w") - vim.notify( - "Created a new cspell.json file at " .. cspell_json_file_path, - vim.log.levels.INFO, - { title = "cspell.nvim" } - ) + if existing_config ~= nil then + local existing_import_set = set_create(existing_config.config.import) + local new_import_set = set_create(cspell_json.import) - local info = { - config = cspell_json, - path = cspell_json_file_path, - } + if set_compare(existing_import_set, new_import_set) and set_compare(new_import_set, existing_import_set) then + CONFIG_INFO_BY_PATH[merged_config_path] = existing_config + return CONFIG_INFO_BY_PATH[merged_config_path] + end + end - CONFIG_INFO_BY_CWD[params.cwd] = info + return create_cspell_json(params, cspell_json, merged_config_path) +end - return info +--- create a bare minimum cspell.json file +---@param params GeneratorParams +---@param cspell_json_file_path string +---@return CSpellConfigInfo +M.create_default_cspell_json = function(params, cspell_json_file_path) + local cspell_json = { + version = "0.2", + language = "en", + words = {}, + flagWords = {}, + } + return create_cspell_json(params, cspell_json, cspell_json_file_path) end ---@param word string @@ -69,7 +120,9 @@ M.cache_word_for_json = function(word) end ---@param params GeneratorParams -M.add_words_to_json = function(params, words) +---@param words table +---@param cspell_json_path string +M.add_words_to_json = function(params, words, cspell_json_path) if not words or #words == 0 then return end @@ -80,7 +133,7 @@ M.add_words_to_json = function(params, words) local on_add_to_json = code_action_config.on_add_to_json local encode_json = code_action_config.encode_json or vim.json.encode - local cspell = M.sync_get_config_info(params) + local cspell = M.sync_get_config_info(params, cspell_json_path) if not cspell.config.words then cspell.config.words = {} @@ -90,13 +143,7 @@ M.add_words_to_json = function(params, words) local misspelled_words = table.concat(words, ", ") local encoded = encode_json(cspell.config) or "" - local lines = {} - for line in encoded:gmatch("[^\r\n]+") do - table.insert(lines, line) - end - local json_str = table.concat(lines, "") - - Path:new(cspell.path):write(json_str, "w") + Path:new(cspell.path):write(encoded, "w") vim.notify('Added "' .. misspelled_words .. '" to ' .. cspell.path, vim.log.levels.INFO, { title = "cspell.nvim" }) if on_success then @@ -119,38 +166,39 @@ M.add_words_to_json = function(params, words) end end ----@param filename string ----@param cwd string +--- Find the first cspell.json file in the directory tree +---@param directory string ---@return string|nil -local function find_file(filename, cwd) - ---@type string|nil - local current_dir = cwd - local root_dir = "/" - - repeat - local file_path = current_dir .. "/" .. filename - local stat = uv.fs_stat(file_path) - if stat and stat.type == "file" then - return file_path - end - - current_dir = uv.fs_realpath(current_dir .. "/..") - until current_dir == root_dir +M.find_cspell_config_path = function(directory) + directory = vim.fs.normalize(directory) + local files = vim.fs.find(CSPELL_CONFIG_FILES, { path = directory, upward = true, type = "file" }) + if files and files[1] then + return files[1] + end return nil end ---- Find the first cspell.json file in the directory tree ----@param cwd string ----@return string|nil -local find_cspell_config_path = function(cwd) - for _, file in ipairs(CSPELL_CONFIG_FILES) do - local path = find_file(file, cwd or vim.loop.cwd()) - if path then - return path - end +--- Generate a cspell json path +---@param params GeneratorParams +---@param directory string +---@return string +M.generate_cspell_config_path = function(params, directory) + local code_action_config = params:get_config() + local config_file_preferred_name = code_action_config.config_file_preferred_name or "cspell.json" + if not vim.tbl_contains(CSPELL_CONFIG_FILES, config_file_preferred_name) then + vim.notify( + "Invalid config_file_preferred_name for cspell json file: " + .. config_file_preferred_name + .. '. The name "cspell.json" will be used instead', + vim.log.levels.WARN, + { title = "cspell.nvim" } + ) + config_file_preferred_name = "cspell.json" end - return nil + + local config_path = require("null-ls.utils").path.join(directory, config_file_preferred_name) + return vim.fs.normalize(config_path) end ---@class GeneratorParams @@ -163,15 +211,15 @@ end ---@field get_config function ---@param params GeneratorParams +---@param cspell_json_path string ---@return CSpellConfigInfo|nil -M.get_cspell_config = function(params) +M.get_cspell_config = function(params, cspell_json_path) ---@type CSpellSourceConfig local code_action_config = params:get_config() local decode_json = code_action_config.decode_json or vim.json.decode - local cspell_json_path = M.get_config_path(params) - - if cspell_json_path == nil or cspell_json_path == "" then + local path_exists = cspell_json_path ~= nil and cspell_json_path ~= "" and Path:new(cspell_json_path):exists() + if not path_exists then return end @@ -192,38 +240,45 @@ end --- Non-blocking config parser --- The first run is meant to be a cache warm up ---@param params GeneratorParams +---@param cspell_json_path string ---@return CSpellConfigInfo|nil -M.async_get_config_info = function(params) +M.async_get_config_info = function(params, cspell_json_path) ---@type uv_async_t|nil local async async = vim.loop.new_async(function() - M.sync_get_config_info(params) - M.add_words_to_json(params, CACHED_JSON_WORDS) + M.sync_get_config_info(params, cspell_json_path) + M.add_words_to_json(params, CACHED_JSON_WORDS, cspell_json_path) CACHED_JSON_WORDS = {} async:close() end) async:send() - return CONFIG_INFO_BY_CWD[params.cwd] + return CONFIG_INFO_BY_PATH[cspell_json_path] end -M.sync_get_config_info = function(params) - if CONFIG_INFO_BY_CWD[params.cwd] == nil then - local config = M.get_cspell_config(params) - CONFIG_INFO_BY_CWD[params.cwd] = config +---@param params GeneratorParams +---@param cspell_json_path string +---@return CSpellConfigInfo|nil +M.sync_get_config_info = function(params, cspell_json_path) + if CONFIG_INFO_BY_PATH[cspell_json_path] == nil then + local config = M.get_cspell_config(params, cspell_json_path) + CONFIG_INFO_BY_PATH[cspell_json_path] = config end - return CONFIG_INFO_BY_CWD[params.cwd] + return CONFIG_INFO_BY_PATH[cspell_json_path] end -M.get_config_path = function(params) - if PATH_BY_CWD[params.cwd] == nil then +---@param params GeneratorParams +---@param directory string +---@return string|nil +M.get_config_path = function(params, directory) + if PATH_BY_DIRECTORY[directory] == nil then local code_action_config = params:get_config() - local find_json = code_action_config.find_json or find_cspell_config_path - local cspell_json_path = find_json(params.cwd) - PATH_BY_CWD[params.cwd] = cspell_json_path + local find_json = code_action_config.find_json or M.find_cspell_config_path + local cspell_json_path = find_json(directory) + PATH_BY_DIRECTORY[directory] = cspell_json_path end - return PATH_BY_CWD[params.cwd] + return PATH_BY_DIRECTORY[directory] end --- Checks that both sources use the same config @@ -265,8 +320,28 @@ M.set_word = function(diagnostic, new_word) end M.clear_cache = function() - PATH_BY_CWD = {} - CONFIG_INFO_BY_CWD = {} + CONFIG_INFO_BY_PATH = {} + PATH_BY_DIRECTORY = {} +end + +--- Convert a given path to a format using as few characters as possible +--- @param path string +--- @return string +M.shorten_path = function(path) + return Path:new(path):expand():gsub(Path:new("."):expand(), "."):gsub(vim.env.HOME, "~") +end + +--- Formats a string using a table of substitutions. +--- E.g. `M.format("Hello ${subject}", { subject = "world" })` returns `Hello world` +--- +--- @param str string The string to format +--- @param tbl table k-v pairs of string substitutions +--- @return string, number +M.format = function(str, tbl) + ---@param param string + return str:gsub("$%b{}", function(param) + return (tbl[string.sub(param, 3, -2)] or param) + end) end return M @@ -301,6 +376,7 @@ return M ---@field version string ---@field words table ---@field dictionaryDefinitions table|nil +---@field import table|nil ---@class CSpellDictionary ---@field name string @@ -309,6 +385,7 @@ return M ---@class CSpellSourceConfig ---@field config_file_preferred_name string|nil +---@field cspell_config_dirs table|nil --- Will find and read the cspell config file synchronously, as soon as the --- code actions generator gets called. --- diff --git a/tests/spec/diagnostics_spec.lua b/tests/spec/diagnostics_spec.lua index 43af85d..be765c1 100644 --- a/tests/spec/diagnostics_spec.lua +++ b/tests/spec/diagnostics_spec.lua @@ -11,6 +11,10 @@ local uv = vim.loop local CSPELL_CONFIG_PATH = uv.fs_realpath("./cspell.json") +local CACHE_KEY = Path:new("."):joinpath("cspell.json"):absolute():gsub("/", "%%") + +local CSPELL_MERGED_CONFIG_PATH = Path:new(".tests/cache/nvim/cspell.nvim"):joinpath(CACHE_KEY):expand() + mock(require("null-ls.logger"), true) describe("diagnostics", function() @@ -78,7 +82,8 @@ describe("diagnostics", function() local add_to_json_action local actions = code_actions.generator.fn(generator_params) for _, action in ipairs(actions) do - if action.title:match("cspell json file") then + local expected_action_title = 'to "' .. helpers.shorten_path(generator_params.cwd) + if action.title:match(expected_action_title) then add_to_json_action = action break end @@ -113,7 +118,7 @@ describe("diagnostics", function() it("does not include a suggestions param", function() assert.same({ "-c", - CSPELL_CONFIG_PATH, + CSPELL_MERGED_CONFIG_PATH, "lint", "--language-id", "lua", @@ -150,7 +155,7 @@ describe("diagnostics", function() assert.same({ "--show-suggestions", "-c", - CSPELL_CONFIG_PATH, + CSPELL_MERGED_CONFIG_PATH, "lint", "--language-id", "lua", @@ -170,6 +175,7 @@ describe("diagnostics", function() after_each(function() get_source:revert() async_get_config_info:revert() + Path:new(CSPELL_MERGED_CONFIG_PATH):rm({}) end) it("includes a suggestions param", function() @@ -187,12 +193,20 @@ describe("diagnostics", function() assert.same({ "-c", - "some/custom/path/cspell.json", + CSPELL_MERGED_CONFIG_PATH, "lint", "--language-id", "lua", "stdin://file.txt", }, args) + + local merged_config = vim.json.decode(Path:new(CSPELL_MERGED_CONFIG_PATH):read()) + + assert.is_table(merged_config) + assert.truthy(merged_config["import"] ~= nil) + + assert.is_table(merged_config.import) + assert.truthy(vim.tbl_contains(merged_config.import, "some/custom/path/cspell.json")) end) end)