aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJustin M. Keyes <justinkz@gmail.com>2026-10-05 14:10:04 +0200
committerJustin M. Keyes <justinkz@gmail.com>2026-10-05 14:10:35 +0200
commit3561cff9786fb83f56fb4a9b59d510f56cf8e22e (patch)
tree321c59e1effc897704d1d2b323c94ee9f76ffef9
parentdocs: update generated annotations (diff)
downloadnvim-lspconfig-3561cff9786fb83f56fb4a9b59d510f56cf8e22e.tar
nvim-lspconfig-3561cff9786fb83f56fb4a9b59d510f56cf8e22e.tar.gz
nvim-lspconfig-3561cff9786fb83f56fb4a9b59d510f56cf8e22e.tar.bz2
nvim-lspconfig-3561cff9786fb83f56fb4a9b59d510f56cf8e22e.tar.lz
nvim-lspconfig-3561cff9786fb83f56fb4a9b59d510f56cf8e22e.tar.xz
nvim-lspconfig-3561cff9786fb83f56fb4a9b59d510f56cf8e22e.tar.zst
nvim-lspconfig-3561cff9786fb83f56fb4a9b59d510f56cf8e22e.zip
fix(gen): annotate $ref defs once, sanitize schema strings
Problem: Inlining every `$ref` bloats ruff.lua 20x, which also breaks it in LuaLS. Recursive or repeated refs error out or expand exponentially. Schema strings can inject Lua into generated files (`\r`, raw `type`). Failed schemas are silently dropped. Solution: - Annotate each `$defs`/`definitions` entry once and refer to it by name. - Allowlist JSON types; normalize `\r` in comments. - Fail CI and list each broken URL. - Fix `const` with multi-valued `type`, dedupe unions, handle boolean subschemas.
-rw-r--r--lsp/emmylua_ls.lua1
-rw-r--r--scripts/gen_annotations.lua207
-rw-r--r--scripts/gen_json_schemas.lua108
3 files changed, 145 insertions, 171 deletions
diff --git a/lsp/emmylua_ls.lua b/lsp/emmylua_ls.lua
index 3d92e35b..1f22ee32 100644
--- a/lsp/emmylua_ls.lua
+++ b/lsp/emmylua_ls.lua
@@ -72,6 +72,7 @@ return {
root_markers = vim.fn.has('nvim-0.11.3') == 1 and { root_markers1, root_markers2, { '.git' } }
or vim.list_extend(vim.list_extend(root_markers1, root_markers2), { '.git' }),
workspace_required = false,
+ ---@type lspconfig.settings.emmylua_ls
settings = {
emmylua = {
codeLens = { enable = true },
diff --git a/scripts/gen_annotations.lua b/scripts/gen_annotations.lua
index 1ca3d100..716ea1ec 100644
--- a/scripts/gen_annotations.lua
+++ b/scripts/gen_annotations.lua
@@ -134,7 +134,8 @@ end
local function format_comment(desc, prefix)
if desc then
prefix = (prefix or '') .. '---'
- return prefix .. desc:gsub('\n', '\n' .. prefix)
+ -- Lua also ends a comment at `\r`.
+ return prefix .. desc:gsub('\r\n?', '\n'):gsub('\n', '\n' .. prefix)
end
end
@@ -191,19 +192,14 @@ local function segment_name_for_annotation(segment)
return #words > 0 and table.concat(words) or '_'
end
----Build the Lua class name for a schema node.
----@param path string[] Path segments from the schema root.
----@param root_class string Root class name.
+---Build the Lua class name for a nested schema object.
+---`lspconfig.settings.x` + `foo` => `_.lspconfig.settings.x.Foo`, then + `bar` => `_.lspconfig.settings.x.Foo.Bar`.
+---@param class_name string Parent class name.
+---@param field string
---@return string
-local function class_name_for(path, root_class)
- if #path == 0 then
- return root_class
- end
- local class_name = { '_', root_class }
- for _, segment in ipairs(path) do
- table.insert(class_name, segment_name_for_annotation(segment))
- end
- return table.concat(class_name, '.')
+local function child_class_name(class_name, field)
+ local parent = vim.startswith(class_name, '_.') and class_name or ('_.%s'):format(class_name)
+ return ('%s.%s'):format(parent, segment_name_for_annotation(field))
end
---@type table<string, true>
@@ -243,57 +239,76 @@ local function field_name_for_annotation(field)
return '[' .. vim.inspect(field) .. ']'
end
+---LuaLS type of each JSON Schema `type`, except `array` (see lua_type_for). Others (`null`, invalid) are dropped.
+---@type table<string, string>
+local json_types = {
+ boolean = 'boolean',
+ integer = 'integer',
+ number = 'number',
+ object = 'table',
+ string = 'string',
+}
+
+---Format a JSON value as a LuaLS literal type.
+---@param value any
+---@return string
+local function literal_type_for(value)
+ return type(value) == 'table' and 'table' or vim.inspect(value)
+end
+
---Convert a schema property into a Lua type.
----@param prop table Schema property.
+---@param prop any Schema property.
+---@param refs table<string, string> Class name of each `$ref` target (see generate_file_annotations).
---@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),
- ' | '
- )
+local function lua_type_for(prop, refs)
+ if type(prop) ~= 'table' then
+ return 'any'
end
- if prop.const ~= nil then
- return vim.inspect(prop.const)
+ if prop['$ref'] then
+ return refs[prop['$ref']] or 'any'
end
- local types = type(prop.type) == 'table' and prop.type or { prop.type }
- -- Convert `anyOf`/`oneOf` to a union, excluding null:
- -- `{ anyOf = { { type = 'string' }, { type = 'number' }, { type = 'null' } } }` => `string|number`.
- local alternatives = prop.anyOf or prop.oneOf
- if vim.tbl_isempty(types) and type(alternatives) == 'table' then
- local alternative_types = {}
- for _, alternative in ipairs(alternatives) do
- if alternative.type ~= 'null' then
- table.insert(alternative_types, lua_type_for(alternative))
- end
- end
- return #alternative_types > 0 and table.concat(alternative_types, '|') or 'any'
+ if prop.const ~= nil and type(prop.type) ~= 'table' then
+ return literal_type_for(prop.const)
end
- types = vim.tbl_map(function(t)
- if t == 'null' then
- return
- end
- if t == 'array' then
- if type(prop.items) == 'table' then
- local item_type = lua_type_for(prop.items)
- if item_type:find('|', 1, true) then
- item_type = '(' .. item_type .. ')'
- end
- return item_type .. '[]'
+ local types = {}
+ if prop.enum then
+ types = vim.tbl_map(literal_type_for, prop.enum)
+ elseif prop.type then
+ for _, t in ipairs(type(prop.type) == 'table' and prop.type or { prop.type }) do
+ if t == 'array' then
+ local item_type = lua_type_for(prop.items, refs)
+ table.insert(types, (item_type:find('|', 1, true) and '(%s)[]' or '%s[]'):format(item_type))
+ elseif json_types[t] then
+ table.insert(types, json_types[t])
end
- return 'any[]'
end
- if t == 'object' then
- return 'table'
+ else
+ -- Convert `anyOf`/`oneOf` to a union, excluding null:
+ -- `{ anyOf = { { type = 'string' }, { type = 'number' }, { type = 'null' } } }` => `string|number`.
+ for _, alternative in ipairs(prop.anyOf or prop.oneOf or {}) do
+ if type(alternative) ~= 'table' or alternative.type ~= 'null' then
+ table.insert(types, lua_type_for(alternative, refs))
+ end
end
- return t
- end, types)
- if vim.tbl_isempty(types) then
- types = { 'any' }
end
- return table.concat(vim.iter(types):flatten():totable(), '|')
+ return #types > 0 and table.concat(vim.list.unique(types), '|') or 'any'
+end
+
+---Get the object schema (with `properties`) declared by `prop`, if any.
+---`{ anyOf = { { type = 'object', properties = … }, { type = 'null' } } }` => the first alternative.
+---@param prop any Schema property.
+---@return table?
+local function object_schema(prop)
+ if type(prop) ~= 'table' then
+ return nil
+ end
+ if prop.type == 'object' and prop.properties then
+ return prop
+ end
+ local alternatives = vim.tbl_filter(function(alternative)
+ return type(alternative) ~= 'table' or alternative.type ~= 'null'
+ end, prop.anyOf or prop.oneOf or {})
+ return #alternatives == 1 and object_schema(alternatives[1]) or nil
end
---Return whether a field is required by its parent schema.
@@ -322,39 +337,58 @@ end
---Append annotations for an object node and its children.
---@param lines string[] Output buffer.
----@param path string[] Path segments from the schema root.
+---@param class_name string
---@param prop table Object property schema.
----@param root_class string Root class name.
-local function append_object(lines, path, prop, root_class)
+---@param refs table<string, string> (see lua_type_for)
+local function append_object(lines, class_name, prop, refs)
local object_lines = {}
append_description(object_lines, prop)
- table.insert(object_lines, '---@class ' .. class_name_for(path, 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]
- local optional_marker = is_required_field(prop, field, child) and '' or '?'
- local field_name = field_name_for_annotation(field)
- append_description(object_lines, child)
+ table.insert(object_lines, ('---@class %s'):format(class_name))
+ local fields = vim.tbl_keys(prop.properties or {})
+ table.sort(fields)
+ for _, field in ipairs(fields) do
+ local child = type(prop.properties[field]) == 'table' and prop.properties[field] or {}
+ local optional_marker = is_required_field(prop, field, child) and '' or '?'
+ append_description(object_lines, child)
- if child.type == 'object' and child.properties then
- local child_path = vim.deepcopy(path)
- table.insert(child_path, field)
- table.insert(
- object_lines,
- '---@field ' .. field_name .. optional_marker .. ' ' .. class_name_for(child_path, root_class)
- )
- append_object(lines, child_path, child, root_class)
- else
- table.insert(object_lines, '---@field ' .. field_name .. optional_marker .. ' ' .. lua_type_for(child))
- end
+ local object = object_schema(child)
+ local field_type = object and child_class_name(class_name, field) or lua_type_for(child, refs)
+ table.insert(
+ object_lines,
+ ('---@field %s%s %s'):format(field_name_for_annotation(field), optional_marker, field_type)
+ )
+ if object then
+ append_object(lines, field_type, object, refs)
end
end
table.insert(lines, '')
vim.list_extend(lines, object_lines)
end
+---Append annotations for a schema definition.
+---@param lines string[] Output buffer.
+---@param class_name string
+---@param def table Definition schema.
+---@param refs table<string, string> (see lua_type_for)
+local function append_definition(lines, class_name, def, refs)
+ local object = object_schema(def)
+ if object then
+ append_object(lines, class_name, object, refs)
+ return
+ end
+ table.insert(lines, '')
+ append_description(lines, def)
+ if not def.enum then
+ table.insert(lines, ('---@alias %s %s'):format(class_name, lua_type_for(def, refs)))
+ return
+ end
+ -- One member per line keeps diffs small for huge enums (e.g. ruff `RuleSelector`).
+ table.insert(lines, ('---@alias %s'):format(class_name))
+ for _, value in ipairs(def.enum) do
+ table.insert(lines, ('---| %s'):format(literal_type_for(value)))
+ end
+end
+
---Generate annotation lines for one schema file.
---@param file string Schema file path.
---@return string[]
@@ -364,13 +398,28 @@ local function generate_file_annotations(file)
local class_name = 'lspconfig.settings.' .. name
local lines = { '---@meta' }
+ -- `{ ['$ref'] = '#/$defs/Foo' }` => `_.lspconfig.settings.<name>.defs.Foo`, annotated once.
+ local refs = {} ---@type table<string, string>
+ local defs = {} ---@type table<string, table>
+ for _, key in ipairs({ '$defs', 'definitions' }) do
+ for def_name, def in pairs(json[key] or {}) do
+ local def_class = ('_.%s.defs.%s'):format(class_name, segment_name_for_annotation(def_name))
+ refs[('#/%s/%s'):format(key, def_name)] = def_class
+ defs[def_class] = type(def) == 'table' and def or {}
+ end
+ end
+ for def_class, def in vim.spairs(defs) do
+ append_definition(lines, def_class, def, refs)
+ end
+
local schema = Settings.new()
for key, prop in pairs(json.properties) do
+ prop = type(prop) == 'table' and prop or {}
prop.leaf = true
schema:set(key, prop)
end
- append_object(lines, {}, normalize_properties(schema:get()), class_name)
+ append_object(lines, class_name, normalize_properties(schema:get()), refs)
return vim.tbl_filter(function(v)
return v ~= nil
end, lines)
diff --git a/scripts/gen_json_schemas.lua b/scripts/gen_json_schemas.lua
index c1a9b109..a9706cde 100644
--- a/scripts/gen_json_schemas.lua
+++ b/scripts/gen_json_schemas.lua
@@ -160,73 +160,6 @@ local function resolve_schema_configs()
return vim.tbl_deep_extend('force', schemas, overrides)
end
----Resolve local JSON pointer references in a schema node.
----For root `{"$defs":{"port":{"type":"integer"}}}`, `{"$ref":"#/$defs/port"}` => `{"type":"integer"}`.
----`#` => `root`; `#/items/0` => `root.items[1]`.
----`#/foo//bar` => `root.foo[''].bar`; `#/%24defs/port` => `root['$defs'].port`.
----@param node any
----@param root table
----@param resolving? table<string, true>
----@return any
-local function resolve_local_refs(node, root, resolving)
- if type(node) ~= 'table' then
- return node
- end
-
- local ref = node['$ref']
- local pointer
- if type(ref) == 'string' and vim.startswith(ref, '#') then
- pointer = vim.uri_decode(ref:sub(2))
- end
- if pointer == nil or (pointer ~= '' and not vim.startswith(pointer, '/')) then
- for key, value in pairs(node) do
- node[key] = resolve_local_refs(value, root, resolving)
- end
- return node
- end
-
- resolving = resolving or {}
- if resolving[ref] then
- error('Cyclic schema reference: ' .. ref)
- end
-
- local target = root
- -- `#/$defs/foo~1bar~0baz` resolves to `root['$defs']['foo/bar~baz']` (`~1` escapes `/`, `~0` escapes `~`).
- for part in pointer:gmatch('/([^/]*)') do
- local token = part:gsub('~1', '/'):gsub('~0', '~')
- ---@type string|integer
- local key = token
- if type(target) ~= 'table' then
- error('Could not resolve schema reference: ' .. ref)
- end
- if vim.islist(target) then
- if token ~= '0' and not token:match('^[1-9]%d*$') then
- error('Could not resolve schema reference: ' .. ref)
- end
- key = assert(tonumber(token)) + 1
- end
- if target[key] == nil then
- error('Could not resolve schema reference: ' .. ref)
- end
- target = target[key]
- end
-
- resolving[ref] = true
- local resolved = resolve_local_refs(vim.deepcopy(target), root, resolving)
- resolving[ref] = nil
- for key, value in pairs(node) do
- if key ~= '$ref' then
- if type(resolved) ~= 'table' or resolved[key] ~= nil then
- -- TODO: Preserve conflicting constraints with `allOf` once annotation generation supports it.
- -- See https://json-schema.org/draft/2020-12/json-schema-core#section-8.2.3.1.
- error(('Cannot merge schema reference sibling %q: %s'):format(key, ref))
- end
- resolved[key] = resolve_local_refs(value, root, resolving)
- end
- end
- return resolved
-end
-
---Replaces localized documentation placeholders in a schema tree in place.
---
---This is used for schemas whose documentation strings are stored in a
@@ -282,40 +215,24 @@ 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
+ if not config_schema then
+ error('Missing `contributes.configuration` or `properties`')
+ end
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
- if k ~= '$schema' then
- properties[k] = resolve_local_refs(v, config_section)
- end
- end
- end
- end
- elseif config_schema.properties then
- for k, v in pairs(config_schema.properties) do
+ for _, config_section in ipairs(vim.islist(config_schema) and config_schema or { config_schema }) do
+ for k, v in pairs(config_section.properties or {}) do
if k ~= '$schema' then
- properties[k] = resolve_local_refs(v, config_schema)
+ properties[('%s%s'):format(schema.prefix or '', k)] = v
end
end
end
- -- `properties["enable_snippets"]` => `properties["zls.enable_snippets"]`
- if schema.prefix then
- if type(properties) == 'table' then
- local new = vim.empty_dict()
- for key, value in pairs(properties) do
- new[schema.prefix .. key] = value
- end
- properties = new
- end
- end
-
local schema_json = {
['$schema'] = 'http://json-schema.org/draft-07/schema#',
+ -- Targets of local `$ref`s (see gen_annotations.lua).
+ ['$defs'] = package_json['$defs'],
+ definitions = package_json.definitions,
description = package_json.description,
properties = properties,
}
@@ -335,6 +252,8 @@ local function generate_all_schemas()
local schemas = resolve_schema_configs()
local names = vim.tbl_keys(schemas)
table.sort(names)
+ -- Try all servers before failing, to report every broken URL in one run.
+ local failures = {} ---@type string[]
for _, name in ipairs(names) do
local schema_config = schemas[name]
print(('Generating schema for %s'):format(name))
@@ -348,9 +267,14 @@ local function generate_all_schemas()
schema_config.settings_file,
'b'
)
+ else
+ table.insert(failures, ('%s (%s): %s'):format(name, schema_config.package_url, schema_json))
end
end
end
+ if #failures > 0 then
+ error(('Failed to generate schemas:\n%s'):format(table.concat(failures, '\n')))
+ end
end
generate_all_schemas()