aboutsummaryrefslogtreecommitdiffstats
path: root/scripts
diff options
context:
space:
mode:
authorYi Ming <ofseed@foxmail.com>2026-03-13 15:25:53 +0800
committerYi Ming <ofseed@foxmail.com>2026-03-13 22:45:20 +0800
commit67bd0e6c9ec8f650524ef14721ace894f2b6f647 (patch)
treed8eb730d8d118d4e8d231a6e7548556374d06ffb /scripts
parentdocs: update configs.md (diff)
downloadnvim-lspconfig-67bd0e6c9ec8f650524ef14721ace894f2b6f647.tar
nvim-lspconfig-67bd0e6c9ec8f650524ef14721ace894f2b6f647.tar.gz
nvim-lspconfig-67bd0e6c9ec8f650524ef14721ace894f2b6f647.tar.bz2
nvim-lspconfig-67bd0e6c9ec8f650524ef14721ace894f2b6f647.tar.lz
nvim-lspconfig-67bd0e6c9ec8f650524ef14721ace894f2b6f647.tar.xz
nvim-lspconfig-67bd0e6c9ec8f650524ef14721ace894f2b6f647.tar.zst
nvim-lspconfig-67bd0e6c9ec8f650524ef14721ace894f2b6f647.zip
feat: auto-generate annotations for LSP settings
Diffstat (limited to 'scripts')
-rw-r--r--scripts/gen_annotations.lua308
-rw-r--r--scripts/gen_json_schemas.lua247
2 files changed, 555 insertions, 0 deletions
diff --git a/scripts/gen_annotations.lua b/scripts/gen_annotations.lua
new file mode 100644
index 00000000..a8f4d1b8
--- /dev/null
+++ b/scripts/gen_annotations.lua
@@ -0,0 +1,308 @@
+---Merge maps recursively and replace non-map values.
+---@param ... any Values to merge in order.
+---@return any
+local function merge(...)
+ ---Return whether a value can be merged as a map.
+ ---@param value any Candidate value.
+ ---@return boolean
+ local function is_mergeable(value)
+ return type(value) == 'table' and (vim.tbl_isempty(value) or not vim.islist(value))
+ end
+
+ local values = { ... }
+ local merged = values[1]
+ for i = 2, #values, 1 do
+ local next_value = values[i]
+ if is_mergeable(merged) and is_mergeable(next_value) then
+ for key, item in pairs(next_value) do
+ merged[key] = merge(merged[key], item)
+ end
+ else
+ merged = next_value
+ end
+ end
+ return merged
+end
+
+---@class Settings
+---@field _settings table
+---@field file string
+local Settings = {}
+Settings.__index = Settings
+
+---Create a settings store from dotted keys.
+---@param settings? table Initial dotted-key values.
+---@return Settings
+function Settings.new(settings)
+ local self = setmetatable({ _settings = {} }, Settings)
+ for key, value in pairs(settings or {}) do
+ self:set(key, value)
+ end
+ return self
+end
+
+---Expand dotted keys in a table into nested tables.
+---@param value any Source value.
+---@return any
+function Settings.expand_keys(value)
+ if type(value) ~= 'table' then
+ return value
+ end
+ local expanded = Settings.new()
+ for key, item in pairs(value) do
+ expanded:set(key, item)
+ end
+ return expanded:get()
+end
+
+---Split a dotted key into path segments.
+---@param key any Dotted key or raw key value.
+---@return any[]
+function Settings.split_key(key)
+ if not key or key == '' then
+ return {}
+ end
+ if type(key) ~= 'string' then
+ return { key }
+ end
+ local parts = {}
+ for part in string.gmatch(key, '[^.]+') do
+ table.insert(parts, part)
+ end
+ return parts
+end
+
+---Store a value at a dotted key path.
+---@param key any Target dotted key.
+---@param value any Value to store.
+function Settings:set(key, value)
+ local parts = Settings.split_key(key)
+
+ if #parts == 0 then
+ self._settings = value
+ return
+ end
+
+ local node = self._settings
+ for i = 1, #parts - 1, 1 do
+ local part = parts[i]
+ if type(node[part]) ~= 'table' then
+ node[part] = {}
+ end
+ node = node[part]
+ end
+ node[parts[#parts]] = value
+end
+
+---Read a value from the settings store.
+---@param key? any Dotted key to read.
+---@param opts? {defaults?:table, expand?:boolean} Read options.
+---@return any
+function Settings:get(key, opts)
+ ---@type table|nil
+ local node = self._settings
+
+ for _, part in ipairs(Settings.split_key(key)) do
+ if type(node) ~= 'table' then
+ node = nil
+ break
+ end
+ node = node[part]
+ end
+
+ if opts and opts.expand and type(node) == 'table' then
+ node = Settings.expand_keys(node)
+ end
+
+ if opts and opts.defaults then
+ if node == nil then
+ return vim.deepcopy(opts.defaults)
+ end
+ if type(node) ~= 'table' then
+ return node
+ end
+ node = merge({}, opts.defaults, node)
+ end
+
+ return node
+end
+
+---Format schema text as Lua comments.
+---@param desc? string Comment body.
+---@param prefix? string Optional line prefix.
+---@return string?
+local function format_comment(desc, prefix)
+ if desc then
+ prefix = (prefix or '') .. '---'
+ return prefix .. desc:gsub('\n', '\n' .. prefix)
+ end
+end
+
+---Append a property's description comment.
+---@param lines string[] Output buffer.
+---@param prop table Schema property.
+---@param prefix? string Optional line prefix.
+local function append_description(lines, prop, prefix)
+ local description = prop.markdownDescription or prop.description
+ if type(description) == 'table' and description.message then
+ description = description.message
+ end
+ if prop.default then
+ if prop.default == vim.NIL then
+ prop.default = nil
+ end
+ if type(prop.default) == 'table' and vim.tbl_isempty(prop.default) then
+ prop.default = {}
+ end
+ description = (description and (description .. '\n\n') or '')
+ .. '```lua\ndefault = '
+ .. vim.inspect(prop.default)
+ .. '\n```'
+ end
+ if description then
+ table.insert(lines, format_comment(description, prefix))
+ end
+end
+
+---Wrap nested schema nodes as object properties.
+---@param node table Schema node tree.
+---@return table
+local function normalize_properties(node)
+ return node.leaf and node
+ or {
+ type = 'object',
+ properties = vim.tbl_map(function(child)
+ return normalize_properties(child)
+ end, node),
+ }
+end
+
+---Build the Lua class name for a schema node.
+---@param name string Node name.
+---@param root_class string Root class name.
+---@return string
+local function class_name_for(name, root_class)
+ if name == root_class then
+ return name
+ end
+ local class_name = { '_', root_class }
+ for word in string.gmatch(name, '([^_]+)') do
+ table.insert(class_name, word:sub(1, 1):upper() .. word:sub(2))
+ end
+ return table.concat(class_name, '.')
+end
+
+---Convert a schema property into a Lua type.
+---@param prop table Schema property.
+---@return string
+local function lua_type_for(prop)
+ if prop.enum then
+ return table.concat(
+ vim.tbl_map(function(e)
+ return vim.inspect(e)
+ end, prop.enum),
+ ' | '
+ )
+ end
+ local types = type(prop.type) == 'table' and prop.type or { prop.type }
+ if vim.tbl_isempty(types) and type(prop.anyOf) == 'table' then
+ return table.concat(
+ vim.tbl_map(function(p)
+ return lua_type_for(p)
+ end, prop.anyOf),
+ '|'
+ )
+ end
+ types = vim.tbl_map(function(t)
+ if t == 'null' then
+ return
+ end
+ if t == 'array' then
+ if prop.items and prop.items.type then
+ if type(prop.items.type) == 'table' then
+ prop.items.type = 'any'
+ end
+ return prop.items.type .. '[]'
+ end
+ return 'any[]'
+ end
+ if t == 'object' then
+ return 'table'
+ end
+ return t
+ end, types)
+ if vim.tbl_isempty(types) then
+ types = { 'any' }
+ end
+ return table.concat(vim.iter(types):flatten():totable(), '|')
+end
+
+---Append annotations for an object node and its children.
+---@param lines string[] Output buffer.
+---@param name string Node name.
+---@param prop table Object property schema.
+---@param root_class string Root class name.
+local function append_object(lines, name, prop, root_class)
+ local object_lines = {}
+ append_description(object_lines, prop)
+ table.insert(object_lines, '---@class ' .. class_name_for(name, root_class))
+ if prop.properties then
+ local props = vim.tbl_keys(prop.properties)
+ table.sort(props)
+ for _, field in ipairs(props) do
+ local child = prop.properties[field]
+ append_description(object_lines, child)
+
+ if child.type == 'object' and child.properties then
+ table.insert(object_lines, '---@field ' .. field .. ' ' .. class_name_for(field, root_class))
+ append_object(lines, field, child, root_class)
+ else
+ table.insert(object_lines, '---@field ' .. field .. ' ' .. lua_type_for(child))
+ end
+ end
+ end
+ table.insert(lines, '')
+ vim.list_extend(lines, object_lines)
+end
+
+---Generate annotation lines for one schema file.
+---@param file string Schema file path.
+---@return string[]
+local function generate_file_annotations(file)
+ local name = vim.fn.fnamemodify(file, ':t:r')
+ local json = vim.json.decode(vim.fn.readblob(file), { luanil = { array = true, object = true } }) or {}
+ local class_name = 'lspconfig.settings.' .. name
+ local lines = { '---@meta' }
+
+ local schema = Settings.new()
+ for key, prop in pairs(json.properties) do
+ prop.leaf = true
+ schema:set(key, prop)
+ end
+
+ append_object(lines, class_name, normalize_properties(schema:get()), class_name)
+ return vim.tbl_filter(function(v)
+ return v ~= nil
+ end, lines)
+end
+
+---Generate Lua annotation files from the schemas directory.
+---@return nil
+local function generate_all_annotations()
+ local schema_dir = vim.fs.joinpath(vim.uv.cwd(), 'schemas')
+ local output_dir = vim.fs.joinpath(vim.uv.cwd(), 'lua', 'lspconfig', 'types', 'lsp')
+
+ vim.fn.delete(output_dir, 'rf')
+ vim.fn.mkdir(output_dir, 'p')
+
+ for name, type in vim.fs.dir(schema_dir) do
+ if type == 'file' and vim.endswith(name, '.json') then
+ local file = vim.fs.joinpath(schema_dir, name)
+ local lines = generate_file_annotations(file)
+ local output_file = vim.fs.joinpath(output_dir, vim.fn.fnamemodify(name, ':r') .. '.lua')
+ vim.fn.writefile(vim.split(table.concat(lines, '\n') .. '\n', '\n', { plain = true }), output_file, 'b')
+ end
+ end
+end
+
+generate_all_annotations()
diff --git a/scripts/gen_json_schemas.lua b/scripts/gen_json_schemas.lua
new file mode 100644
index 00000000..9f62ff48
--- /dev/null
+++ b/scripts/gen_json_schemas.lua
@@ -0,0 +1,247 @@
+-- modified from https://gist.githubusercontent.com/williamboman/a01c3ce1884d4b57cc93422e7eae7702/raw/lsp-packages.json
+local index = {
+ -- nickel_ls = "https://raw.githubusercontent.com/tweag/nickel/master/lsp/client-extension/package.json",
+ ada_ls = 'https://raw.githubusercontent.com/AdaCore/ada_language_server/master/integration/vscode/ada/package.json',
+ astro = 'https://raw.githubusercontent.com/withastro/language-tools/main/packages/vscode/package.json',
+ awk_ls = 'https://raw.githubusercontent.com/Beaglefoot/awk-language-server/master/client/package.json',
+ basedpyright = 'https://raw.githubusercontent.com/DetachHead/basedpyright/main/packages/vscode-pyright/package.json',
+ bashls = 'https://raw.githubusercontent.com/bash-lsp/bash-language-server/master/vscode-client/package.json',
+ clangd = 'https://raw.githubusercontent.com/clangd/vscode-clangd/master/package.json',
+ cssls = 'https://raw.githubusercontent.com/microsoft/vscode/main/extensions/css-language-features/package.json',
+ dartls = 'https://raw.githubusercontent.com/Dart-Code/Dart-Code/master/package.json',
+ denols = 'https://raw.githubusercontent.com/denoland/vscode_deno/main/package.json',
+ elixirls = 'https://raw.githubusercontent.com/elixir-lsp/vscode-elixir-ls/master/package.json',
+ elmls = 'https://raw.githubusercontent.com/elm-tooling/elm-language-client-vscode/master/package.json',
+ eslint = 'https://raw.githubusercontent.com/microsoft/vscode-eslint/main/package.json',
+ flow = 'https://raw.githubusercontent.com/flowtype/flow-for-vscode/master/package.json',
+ fsautocomplete = 'https://raw.githubusercontent.com/ionide/ionide-vscode-fsharp/main/release/package.json',
+ grammarly = 'https://raw.githubusercontent.com/znck/grammarly/main/extension/package.json',
+ hhvm = 'https://raw.githubusercontent.com/slackhq/vscode-hack/master/package.json',
+ hie = 'https://raw.githubusercontent.com/alanz/vscode-hie-server/master/package.json',
+ html = 'https://raw.githubusercontent.com/microsoft/vscode/main/extensions/html-language-features/package.json',
+ intelephense = 'https://raw.githubusercontent.com/bmewburn/vscode-intelephense/master/package.json',
+ java_language_server = 'https://raw.githubusercontent.com/georgewfraser/java-language-server/master/package.json',
+ jdtls = 'https://raw.githubusercontent.com/redhat-developer/vscode-java/master/package.json',
+ jsonls = 'https://raw.githubusercontent.com/microsoft/vscode/master/extensions/json-language-features/package.json',
+ julials = 'https://raw.githubusercontent.com/julia-vscode/julia-vscode/master/package.json',
+ kotlin_language_server = 'https://raw.githubusercontent.com/fwcd/vscode-kotlin/master/package.json',
+ ltex = 'https://raw.githubusercontent.com/valentjn/vscode-ltex/develop/package.json',
+ lua_ls = 'https://raw.githubusercontent.com/LuaLS/vscode-lua/master/package.json',
+ luau_lsp = 'https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/main/editors/code/package.json',
+ omnisharp = 'https://raw.githubusercontent.com/OmniSharp/omnisharp-vscode/master/package.json',
+ perlls = 'https://raw.githubusercontent.com/richterger/Perl-LanguageServer/master/clients/vscode/perl/package.json',
+ perlnavigator = 'https://raw.githubusercontent.com/bscan/PerlNavigator/main/package.json',
+ perlpls = 'https://raw.githubusercontent.com/FractalBoy/perl-language-server/master/client/package.json',
+ powershell_es = 'https://raw.githubusercontent.com/PowerShell/vscode-powershell/main/package.json',
+ psalm = 'https://raw.githubusercontent.com/psalm/psalm-vscode-plugin/master/package.json',
+ puppet = 'https://raw.githubusercontent.com/puppetlabs/puppet-vscode/main/package.json',
+ purescriptls = 'https://raw.githubusercontent.com/nwolverson/vscode-ide-purescript/master/package.json',
+ pylsp = 'https://raw.githubusercontent.com/python-lsp/python-lsp-server/develop/pylsp/config/schema.json',
+ pyright = 'https://raw.githubusercontent.com/microsoft/pyright/master/packages/vscode-pyright/package.json',
+ r_language_server = 'https://raw.githubusercontent.com/REditorSupport/vscode-r-lsp/master/package.json',
+ rescriptls = 'https://raw.githubusercontent.com/rescript-lang/rescript-vscode/master/package.json',
+ rls = 'https://raw.githubusercontent.com/rust-lang/vscode-rust/master/package.json',
+ rome = 'https://raw.githubusercontent.com/rome/tools/main/editors/vscode/package.json',
+ ruff_lsp = 'https://raw.githubusercontent.com/astral-sh/ruff-vscode/main/package.json',
+ rust_analyzer = 'https://raw.githubusercontent.com/rust-analyzer/rust-analyzer/master/editors/code/package.json',
+ solargraph = 'https://raw.githubusercontent.com/castwide/vscode-solargraph/master/package.json',
+ solidity_ls = 'https://raw.githubusercontent.com/juanfranblanco/vscode-solidity/master/package.json',
+ sorbet = 'https://raw.githubusercontent.com/sorbet/sorbet/master/vscode_extension/package.json',
+ sourcekit = 'https://raw.githubusercontent.com/swift-server/vscode-swift/main/package.json',
+ spectral = 'https://raw.githubusercontent.com/stoplightio/vscode-spectral/master/package.json',
+ stylelint_lsp = 'https://raw.githubusercontent.com/bmatcuk/coc-stylelintplus/master/package.json',
+ svelte = 'https://raw.githubusercontent.com/sveltejs/language-tools/master/packages/svelte-vscode/package.json',
+ svlangserver = 'https://raw.githubusercontent.com/eirikpre/VSCode-SystemVerilog/master/package.json',
+ tailwindcss = 'https://raw.githubusercontent.com/tailwindlabs/tailwindcss-intellisense/master/packages/vscode-tailwindcss/package.json',
+ terraformls = 'https://raw.githubusercontent.com/hashicorp/vscode-terraform/master/package.json',
+ tinymist = 'https://raw.githubusercontent.com/Myriad-Dreamin/tinymist/refs/heads/main/editors/vscode/package.json',
+ ts_ls = 'https://raw.githubusercontent.com/microsoft/vscode/main/extensions/typescript-language-features/package.json',
+ typst_lsp = 'https://raw.githubusercontent.com/nvarner/typst-lsp/refs/heads/master/editors/vscode/package.json',
+ volar = 'https://raw.githubusercontent.com/vuejs/language-tools/master/extensions/vscode/package.json',
+ vtsls = 'https://raw.githubusercontent.com/yioneko/vtsls/main/packages/service/configuration.schema.json',
+ vue_ls = 'https://raw.githubusercontent.com/vuejs/vetur/master/package.json',
+ yamlls = 'https://raw.githubusercontent.com/redhat-developer/vscode-yaml/master/package.json',
+ zls = 'https://raw.githubusercontent.com/zigtools/zls-vscode/master/package.json',
+}
+
+---@param url string
+---@return string
+local function request(url)
+ local done = false
+ local err
+ local body
+
+ vim.net.request(url, nil, function(request_err, response)
+ err = request_err
+ body = response and response.body or nil
+ done = true
+ end)
+
+ vim.wait(30000, function()
+ return done
+ end, 50)
+
+ if not done then
+ error(('Timed out downloading %s'):format(url))
+ end
+
+ if err then
+ error(('Could not download %s: %s'):format(url, err))
+ end
+
+ return body
+end
+
+---@class LspSchema
+---@field package_url string url of the package.json of the LSP server
+---@field settings_file string file of the settings json schema of the LSP server
+---@field translate? boolean
+
+--- @type table<string, LspSchema>
+local overrides = {
+ lua_ls = {
+ translate = true,
+ },
+ jsonls = {
+ translate = true,
+ },
+ ts_ls = {
+ translate = true,
+ },
+ ltex = {
+ translate = true,
+ },
+ html = {
+ translate = true,
+ },
+ cssls = {
+ translate = true,
+ },
+}
+
+---Builds the effective schema configuration table for each server.
+---@return table<string, LspSchema>
+local function resolve_schema_configs()
+ ---@type table<string, LspSchema>
+ local schemas = {}
+
+ for server, package_json in pairs(index) do
+ schemas[server] = {
+ package_url = package_json,
+ settings_file = vim.fs.joinpath(vim.uv.cwd(), 'schemas', server .. '.json'),
+ }
+ end
+
+ return vim.tbl_deep_extend('force', schemas, overrides)
+end
+
+---Replaces localized documentation placeholders in a schema tree in place.
+---
+---This is used for schemas whose documentation strings are stored in a
+---separate `package.nls.json` file instead of inline in `package.json`.
+---@param props table
+---@param nls_url string
+local function translate_schema_docs(props, nls_url)
+ local localization = vim.json.decode(request(nls_url), { luanil = { array = true, object = true } }) or {}
+
+ ---Resolves a single description value from the downloaded NLS payload.
+ ---@param desc string
+ ---@return string
+ local function resolve_doc_text(desc)
+ desc = localization[desc:gsub('%%', '')] or desc
+ if type(desc) == 'table' then
+ local message_parts = vim.tbl_values(desc)
+ message_parts = vim.iter(message_parts):flatten():totable()
+ table.sort(message_parts)
+ desc = table.concat(message_parts, '\n\n')
+ end
+ return desc
+ end
+
+ ---Walks a schema node recursively and rewrites localized doc fields in place.
+ ---@param node any
+ local function rewrite_doc_tree(node)
+ if type(node) == 'table' then
+ for k, v in pairs(node) do
+ if
+ k == 'description'
+ or k == 'markdownDescription'
+ or k == 'markdownDeprecationMessage'
+ or k == 'deprecationMessage'
+ then
+ node[k] = resolve_doc_text(v)
+ end
+ if k == 'enumDescriptions' or k == 'markdownEnumDescriptions' then
+ for i, d in ipairs(v) do
+ v[i] = resolve_doc_text(d)
+ end
+ end
+ rewrite_doc_tree(v)
+ end
+ end
+ end
+ rewrite_doc_tree(props)
+end
+
+---Downloads and normalizes the JSON schema for a single server.
+---@param schema LspSchema
+---@return table
+local function generate_server_schema(schema)
+ local package_json = vim.json.decode(request(schema.package_url)) or {}
+ local config_schema = package_json.contributes and package_json.contributes.configuration
+ or package_json.properties and package_json
+
+ local properties = vim.empty_dict()
+
+ if vim.islist(config_schema) then
+ for _, config_section in pairs(config_schema) do
+ if config_section.properties then
+ for k, v in pairs(config_section.properties) do
+ properties[k] = v
+ end
+ end
+ end
+ elseif config_schema.properties then
+ properties = config_schema.properties
+ end
+
+ local schema_json = {
+ ['$schema'] = 'http://json-schema.org/draft-07/schema#',
+ description = package_json.description,
+ properties = properties,
+ }
+
+ if schema.translate then
+ translate_schema_docs(schema_json, schema.package_url:gsub('package%.json$', 'package.nls.json'))
+ end
+
+ return schema_json
+end
+
+---Regenerates all schema files under the local `schemas/` directory.
+local function generate_all_schemas()
+ ---@diagnostic disable-next-line: param-type-mismatch
+ vim.fn.delete(vim.fs.joinpath(vim.uv.cwd(), 'schemas'), 'rf')
+
+ local schemas = resolve_schema_configs()
+ local names = vim.tbl_keys(schemas)
+ table.sort(names)
+ for _, name in ipairs(names) do
+ local schema_config = schemas[name]
+ print(('Generating schema for %s'):format(name))
+
+ if not vim.uv.fs_stat(schema_config.settings_file) then
+ local ok, schema_json = pcall(generate_server_schema, schema_config)
+ if ok then
+ vim.fn.mkdir(vim.fn.fnamemodify(schema_config.settings_file, ':h'), 'p')
+ vim.fn.writefile(
+ vim.split(vim.json.encode(schema_json, { indent = ' ', sort_keys = true }), '\n', { plain = true }),
+ schema_config.settings_file,
+ 'b'
+ )
+ end
+ end
+ end
+end
+
+generate_all_schemas()