diff --git a/lua/lspsettings/config.lua b/lua/lspsettings/config.lua index 0a6652b..77c7954 100644 --- a/lua/lspsettings/config.lua +++ b/lua/lspsettings/config.lua @@ -1,5 +1,6 @@ --- @class Config Config = { + json5 = true, paths = { vim.fs.joinpath(vim.fn.stdpath('config'), "lspsettings"), -- compatibility with `nlsp-settings` plugin @@ -58,4 +59,20 @@ function Config:extend(opts) end end +--- Returns supported settings files extensions +--- @param prefix? string Add prefix to all extensions +--- @return string[] +function Config:extensions(prefix) + local extensions = { "json" } + + if self.json5 then + extensions = { "json5", "jsonc", "json" } + end + + if prefix then + return vim.fn.map(extensions, function(_, ext) return prefix .. ext end) + end + return extensions +end + return Config diff --git a/lua/lspsettings/init.lua b/lua/lspsettings/init.lua index 8761623..465baba 100644 --- a/lua/lspsettings/init.lua +++ b/lua/lspsettings/init.lua @@ -67,7 +67,20 @@ M.open_settings_file = function(args) if not path then return end vim.fn.mkdir(path, "p") - vim.cmd.edit(vim.fs.joinpath(path, server_name .. ".json")) + + local file_names = M.config:extensions(server_name .. ".") + local file_name = file_names[1] + + -- Trying to find existing file + for _, fname in ipairs(file_names) do + local file_path = vim.fs.joinpath(path, fname) + if vim.fn.filereadable(file_path) == 1 then + file_name = fname + break + end + end + + vim.cmd.edit(vim.fs.joinpath(path, file_name)) end --- Setup to read from a settings file. @@ -86,13 +99,13 @@ M.setup = function(opts) -- Bind callback to watch changes in configuration files vim.api.nvim_create_autocmd("BufWritePost", { - pattern = "*.json", + pattern = M.config:extensions("*."), callback = function(event_data) local full_path = event_data.match local relative_path = event_data.file local fname = vim.fs.basename(relative_path) - local server_name = fname:sub(1, -6) + local server_name = fname:gsub("%.json[c5]?$", "") -- Check that this `server_name` really exists if not vim.lsp.config[server_name] then diff --git a/lua/lspsettings/json5.lua b/lua/lspsettings/json5.lua new file mode 100644 index 0000000..fb8f54b --- /dev/null +++ b/lua/lspsettings/json5.lua @@ -0,0 +1,191 @@ +---@diagnostic disable: lowercase-global + +--- @param str string Stringified JSON5 data +--- @param opts table Options similar to `vim.json.decode`. Not used for now +---@diagnostic disable-next-line: unused-local +local function decode(str, opts) + local function tokenize(input) + local tokens = {} + local i = 1 + local len = #input + + local function skip_whitespace() + while i <= len do + local c = input:sub(i, i) + if c:match("[%s\n\r\t]") then + i = i + 1 + elseif c == "/" and input:sub(i + 1, i + 1) == "/" then + i = i + 2 + while i <= len and input:sub(i, i) ~= "\n" do + i = i + 1 + end + elseif c == "/" and input:sub(i + 1, i + 1) == "*" then + i = i + 2 + while i <= len - 1 and not (input:sub(i, i + 1) == "*/") do + i = i + 1 + end + i = i + 2 + else + break + end + end + end + + local function read_line(quote) + i = i + 1 + local output = "" + while i <= len do + local c = input:sub(i, i) + if c == "\\" then + local nextc = input:sub(i + 1, i + 1) + if nextc == "n" then + output = output .. "\n" + elseif nextc == "t" then + output = output .. "\t" + elseif nextc == "r" then + output = output .. "\r" + elseif nextc == quote then + output = output .. quote + else + output = output .. nextc + end + i = i + 2 + elseif c == quote then + i = i + 1 + break + else + output = output .. c + i = i + 1 + end + end + return { type = "string", value = output } + end + + local function read_number() + local start = i + while i <= len and input:sub(i, i):match("[%+%-%.%deE]") do + i = i + 1 + end + local num_str = input:sub(start, i - 1) + if num_str == "NaN" then + return { type = "number", value = 0 / 0 } + elseif num_str == "Infinity" or num_str == "+Infinity" then + return { type = "number", value = math.huge } + elseif num_str == "-Infinity" then + return { type = "number", value = -math.huge } + else + return { type = "number", value = tonumber(num_str) } + end + end + + local function read_identifier() + local start = i + while i <= len and input:sub(i, i):match("[%w%$_]") do + i = i + 1 + end + local word = input:sub(start, i - 1) + if word == "true" then + return { type = "boolean", value = true } + elseif word == "false" then + return { type = "boolean", value = false } + elseif word == "null" then + return { type = "null", value = nil } + elseif word == "NaN" or word == "Infinity" or word == "-Infinity" then + i = start + return read_number() + else + return { type = "identifier", value = word } + end + end + + while i <= len do + skip_whitespace() + local c = input:sub(i, i) + if c == "" then + break + elseif c == "{" or c == "}" or c == "[" or c == "]" or c == ":" or c == "," then + table.insert(tokens, { type = "symbol", value = c }) + i = i + 1 + elseif c == '"' or c == "'" then + table.insert(tokens, read_line(c)) + elseif c:match("[%+%-%.%d]") then + table.insert(tokens, read_number()) + elseif c:match("[%a$_]") then + table.insert(tokens, read_identifier()) + else + error("Unexpected character: " .. c) + end + end + + return tokens + end + + local function parse(tokens) + local pos = 1 + + local function peek() return tokens[pos] end + local function next_tok() + local token = peek() + pos = pos + 1 + return token + end + + local function parse_value() + local token = peek() + if not token then error("Unexpected end") end + if token.type == "string" or token.type == "number" or token.type == "boolean" or token.type == "null" then + return next_tok().value + elseif token.value == "{" then + return parse_object() + elseif token.value == "[" then + return parse_array() + elseif token.type == "identifier" then + return next_tok().value + else + error("Unexpected token: " .. token.value) + end + end + + function parse_array() + local result = {} + assert(next_tok().value == "[") + while peek() and peek().value ~= "]" do + table.insert(result, parse_value()) + if peek().value == "," then next_tok() end + end + assert(next_tok().value == "]") + return result + end + + function parse_object() + local result = {} + assert(next_tok().value == "{") + while peek() and peek().value ~= "}" do + local key_token = next_tok() + local key + if key_token.type == "string" or key_token.type == "identifier" then + key = key_token.value + else + error("Invalid object key") + end + assert(next_tok().value == ":") + local val = parse_value() + result[key] = val + if peek().value == "," then next_tok() end + end + assert(next_tok().value == "}") + return result + end + + return parse_value() + end + + local tokens = tokenize(str) + + return parse(tokens) +end + + +return { + decode = decode, +} diff --git a/lua/lspsettings/loader.lua b/lua/lspsettings/loader.lua index 64dea72..e6f7a11 100644 --- a/lua/lspsettings/loader.lua +++ b/lua/lspsettings/loader.lua @@ -36,7 +36,9 @@ local function set_key(key, value, target) local head = key:sub(1, index - 1) local tail = key:sub(index + 1) - target[head] = target[head] or {} + if type(target[head]) ~= "table" then + target[head] = {} + end set_key(tail, value, target[head]) end @@ -59,12 +61,18 @@ end --- @return string[] function JsonLoader:list_configured_servers() local result = {} + local extensions = self.config:extensions(".") for _, dir in ipairs(self.config.paths) do for name, type in vim.fs.dir(dir, { follow = true }) do - if type == "file" and name:sub(-5) == ".json" then - local server_name = name:sub(1, -6) - result[server_name] = true + if type == "file" then + for _, ext in ipairs(extensions) do + if name:sub(-ext:len()) == ext then + local server_name = name:sub(1, -(ext:len() + 1)) + result[server_name] = true + break + end + end end end end @@ -78,10 +86,15 @@ end function JsonLoader:list_server_configs(server_name) local result = {} local paths = self.config.paths - for _, path in ipairs(paths) do - path = vim.fs.joinpath(path, server_name .. ".json") - if vim.fn.filereadable(path) == 1 then - table.insert(result, path) + local fnames = self.config:extensions(server_name .. ".") + + for _, dir_path in ipairs(paths) do + for _, fname in ipairs(fnames) do + local file_path = vim.fs.joinpath(dir_path, fname) + if vim.fn.filereadable(file_path) == 1 then + table.insert(result, file_path) + break + end end end @@ -92,18 +105,25 @@ end --- @param server_name string --- @return table function JsonLoader:load(server_name) + local decode = vim.json.decode + if self.config.json5 then + decode = require("lspsettings.json5").decode + end + local settings = {} -- reading each config for _, path in ipairs(self:list_server_configs(server_name)) do - local jsoned = table.concat(vim.fn.readfile(path)) - local opts = { luanil = { object = true, array = true } } - local success, data = pcall(vim.json.decode, jsoned, opts) - - if success then - settings = vim.tbl_extend("force", settings, data) - else - vim.notify("Unable to load LSP settings at `" .. path .. "`: " .. vim.inspect(data), vim.log.levels.WARN) + local jsoned = table.concat(vim.fn.readfile(path), "\n") + if jsoned ~= "" then + local opts = { luanil = { object = true, array = true } } + local success, data = pcall(decode, jsoned, opts) + + if success then + settings = vim.tbl_extend("force", settings, data) + else + vim.notify("Unable to load LSP settings at `" .. path .. "`: invalid JSON", vim.log.levels.WARN) + end end end diff --git a/lua/lspsettings/schemas.lua b/lua/lspsettings/schemas.lua index 8f9a3c0..7c9e75c 100644 --- a/lua/lspsettings/schemas.lua +++ b/lua/lspsettings/schemas.lua @@ -7,7 +7,7 @@ Schemas.__index = Schemas --- @return Schemas function Schemas:new() local o = { - schemas = {} + schemas = {}, } setmetatable(o, self) @@ -58,6 +58,7 @@ end --- Scans `./schemas` directory and forms list of available schemas. function Schemas:scan() + local extensions = { "json", "json5", "jsonc" } local init_lua_path = debug.getinfo(1).source:sub(2) local lspsettings_path = vim.fs.dirname(init_lua_path) local lua_path = vim.fs.dirname(lspsettings_path) @@ -73,9 +74,10 @@ function Schemas:scan() if type == "file" and name:sub(-5) == ".json" then local server_name = name:sub(1, -6) local file_path = vim.fs.abspath(vim.fs.joinpath(schemas_path, name)) + local matches = vim.fn.map(extensions, function(_, ext) return server_name .. "." .. ext end) self.schemas[server_name] = { - fileMatch = { name }, + fileMatch = matches, url = "/" .. file_path, } end @@ -84,11 +86,13 @@ end --- Applies given schemas list to `jsonls` function Schemas:apply() + local config = require("lspsettings").config + local extensions = config:extensions() + vim.lsp.config('jsonls', { + filetypes = extensions, settings = { - json = { - schemas = vim.tbl_values(self.schemas), - }, + json = { schemas = vim.tbl_values(self.schemas) }, }, }) end diff --git a/lua/lspsettings/types.lua b/lua/lspsettings/types.lua index c6ba33c..f449181 100644 --- a/lua/lspsettings/types.lua +++ b/lua/lspsettings/types.lua @@ -1,4 +1,5 @@ ---@class lspsettings.types.config +---@field json5 boolean? default true. Experimental ---@field paths string|string[]? ---@field on_settings fun(server_name: string, settings: table): nil?