diff options
| author | Justin M. Keyes <justinkz@gmail.com> | 2026-10-05 14:10:04 +0200 |
|---|---|---|
| committer | Justin M. Keyes <justinkz@gmail.com> | 2026-10-05 14:10:35 +0200 |
| commit | 3561cff9786fb83f56fb4a9b59d510f56cf8e22e (patch) | |
| tree | 321c59e1effc897704d1d2b323c94ee9f76ffef9 | |
| parent | docs: update generated annotations (diff) | |
| download | nvim-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.lua | 1 | ||||
| -rw-r--r-- | scripts/gen_annotations.lua | 207 | ||||
| -rw-r--r-- | scripts/gen_json_schemas.lua | 108 |
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() |
