build: replace LuaLS with EmmyLua

Problem: LuaLS struggles with the generics used in Nvim's runtime,
requiring broad diagnostic suppressions. Indexing is also slow.

Solution: Use EmmyLua for type checks in the build and CI. It offers
more sophisticated type checking, substantially better support for
generics, and much better flow analysis.

Correct the affected annotations. Use `@internal`, supported directly by
EmmyLua, instead of `@nodoc` for shared internal declarations, and
support it in the help parser.

AI-assisted
This commit is contained in:
Lewis Russell
2026-09-07 11:43:51 +01:00
committed by Lewis Russell
parent cd52c77cd5
commit 65ef6fdaee
48 changed files with 231 additions and 159 deletions

View File

@@ -493,9 +493,8 @@ vim.cmd = setmetatable({}, {
--- @param t table<string,function>
__index = function(t, cmd)
t[cmd] = function(...)
local opts --- @type vim.api.keyset.cmd
local opts --- @type vim.api.keyset.cmd & { [integer]: any }
if select('#', ...) == 1 and type(select(1, ...)) == 'table' then
--- @type vim.api.keyset.cmd
opts = select(1, ...)
-- Move indexed positions in opts to opt.args
@@ -506,7 +505,6 @@ vim.cmd = setmetatable({}, {
break
end
opts.args[i] = opts[i]
--- @diagnostic disable-next-line: no-unknown
opts[i] = nil
end
end
@@ -565,7 +563,7 @@ end
---@param bufnr integer Buffer number, or 0 for current buffer
---@param pos1 integer[]|string Start of region as a (line, column) tuple or |getpos()|-compatible string
---@param pos2 integer[]|string End of region as a (line, column) tuple or |getpos()|-compatible string
---@param regtype string [setreg()]-style selection type
---@param regtype string # [setreg()]-style selection type
---@param inclusive boolean Controls whether the ending column is inclusive (see also 'selection').
---@return table region Dict of the form `{linenr = {startcol,endcol}}`. `endcol` is exclusive, and
---whole lines are returned as `{startcol,endcol} = {0,-1}`.

View File

@@ -1331,11 +1331,11 @@ end
do
---@class vim.Ringbuf<T>
---@field private _items table[]
---@field private _items table<integer, T?>
---@field private _idx_read integer
---@field private _idx_write integer
---@field private _size integer
---@overload fun(self): table?
---@overload fun(self: vim.Ringbuf<T>): T?
local Ringbuf = {}
--- Clear all items
@@ -1346,7 +1346,6 @@ do
end
--- Adds an item, overriding the oldest item if the buffer is full.
---@generic T
---@param item T
function Ringbuf.push(self, item)
self._items[self._idx_write] = item
@@ -1357,7 +1356,6 @@ do
end
--- Removes and returns the first unread item
---@generic T
---@return T?
function Ringbuf.pop(self)
local idx_read = self._idx_read
@@ -1371,7 +1369,6 @@ do
end
--- Returns the first unread item without removing it
---@generic T
---@return T?
function Ringbuf.peek(self)
if self._idx_read == self._idx_write then
@@ -1407,7 +1404,7 @@ do
--- - |Ringbuf:clear()|
---
---@param size integer
---@return vim.Ringbuf ringbuf
---@return vim.Ringbuf<any> ringbuf
function vim.ringbuf(size)
local ringbuf = {
_items = {},

View File

@@ -33,10 +33,10 @@ local M = {
},
virt = { -- Stored virt_text state.
last = { {}, {}, {}, {} }, ---@type MsgContent[] status in last cmdline row.
cmd = { {}, {} }, ---@type MsgContent[] [(x)] indicators in cmd window.
msg = { {}, {} }, ---@type MsgContent[] [(x)] indicators in msg window.
top = { {} }, ---@type MsgContent[] [+x] top indicator in dialog window.
bot = { {} }, ---@type MsgContent[] [+x] bottom indicator in dialog window.
cmd = { {}, {} }, ---@type MsgContent[] # [(x)] indicators in cmd window.
msg = { {}, {} }, ---@type MsgContent[] # [(x)] indicators in msg window.
top = { {} }, ---@type MsgContent[] # [+x] top indicator in dialog window.
bot = { {} }, ---@type MsgContent[] # [+x] bottom indicator in dialog window.
idx = { mode = 1, search = 2, cmd = 3, ruler = 4, spill = 1, dupe = 2 },
ids = {}, ---@type { ['last'|'cmd'|'msg'|'top'|'bot']: integer? } Table of mark IDs.
delayed = false, -- Whether placement of 'last' virt_text is delayed.

View File

@@ -288,7 +288,7 @@ function M.timeout(duration, task)
timed_out = true
task:close()
end)
--- @diagnostic disable-next-line: invisible
--- @diagnostic disable-next-line: access-invisible
timer._hidden = true
local result = F.pack_len(M.pawait(task))

View File

@@ -1,6 +1,3 @@
-- LuaLS cannot model the generic annotations used by this vendored implementation.
---@diagnostic disable: no-unknown, undefined-doc-name, luadoc-miss-symbol, missing-return, missing-return-value, param-type-mismatch, return-type-mismatch, redundant-return-value, undefined-field, need-check-nil, await-in-sync
local util = require('vim._core.util')
local future = require('vim.async._future')
local runtime = require('vim.async._runtime')

View File

@@ -1,6 +1,3 @@
-- LuaLS cannot model the generic annotations used by this vendored implementation.
---@diagnostic disable: no-unknown, undefined-doc-name, luadoc-miss-symbol, missing-return, missing-return-value, param-type-mismatch, return-type-mismatch, redundant-return-value, undefined-field, need-check-nil, await-in-sync
local async = require('vim.async._core')
local runtime = require('vim.async._runtime')

View File

@@ -1,9 +1,11 @@
-- LuaLS cannot model the generic annotations used by this vendored implementation.
---@diagnostic disable: no-unknown, undefined-doc-name, luadoc-miss-symbol, missing-return, missing-return-value, param-type-mismatch, return-type-mismatch, redundant-return-value, undefined-field, need-check-nil, await-in-sync
local F = vim.F
local util = require('vim._core.util')
--- @class (internal) vim.async.Future<R>
--- @field private _callbacks table<integer, fun(err?: any, ...: R...)>
--- @field private _callback_pos integer
--- @field private _err? any
--- @field private _result? R[] & { n: integer }
local Future = {}
Future.__index = Future
@@ -22,6 +24,8 @@ function Future:result()
end
end
--- @param callback fun(err?: any, ...: R...)
--- @return fun()
function Future:on_complete(callback)
if self:completed() then
-- Already completed or closed
@@ -42,6 +46,8 @@ function Future:on_complete(callback)
end
end
--- @param err? any
--- @param ... R...
function Future:complete(err, ...)
if self:completed() then
error('Future is already completed', 2)
@@ -69,6 +75,7 @@ function Future:complete(err, ...)
end
end
--- @return vim.async.Future<any>
return function()
return setmetatable({
_callbacks = {},

View File

@@ -1,6 +1,3 @@
-- LuaLS cannot model the generic annotations used by this vendored implementation.
---@diagnostic disable: no-unknown, undefined-doc-name, luadoc-miss-symbol, missing-return, missing-return-value, param-type-mismatch, return-type-mismatch, redundant-return-value, undefined-field, need-check-nil, await-in-sync
local new_event = require('vim.async._event')
--- An optionally bounded FIFO queue for passing values between async tasks.

View File

@@ -1,6 +1,3 @@
-- LuaLS cannot model the generic annotations used by this vendored implementation.
---@diagnostic disable: no-unknown, undefined-doc-name, luadoc-miss-symbol, missing-return, missing-return-value, param-type-mismatch, return-type-mismatch, redundant-return-value, undefined-field, need-check-nil, await-in-sync
local validate = vim.validate
--- @class vim.async.Timer: vim.async.Closable

View File

@@ -1,6 +1,3 @@
-- LuaLS cannot model the generic annotations used by this vendored implementation.
---@diagnostic disable: no-unknown, undefined-doc-name, luadoc-miss-symbol, missing-return, missing-return-value, param-type-mismatch, return-type-mismatch, redundant-return-value, undefined-field, need-check-nil, await-in-sync
local F = vim.F
local new_event = require('vim.async._event')

View File

@@ -3,7 +3,7 @@ local fn = vim.fn
local M = {}
--- @alias vim.filetype.mapfn fun(path:string,bufnr:integer, ...):string?, fun(b:integer)?
--- @alias vim.filetype.mapfn fun(path:string,bufnr:integer, ...):string?, fun(b:integer)?, boolean?
--- @alias vim.filetype.mapopts { priority: number }
--- @alias vim.filetype.maptbl [string|vim.filetype.mapfn, vim.filetype.mapopts]
--- @alias vim.filetype.mapping.value string|vim.filetype.mapfn|vim.filetype.maptbl

View File

@@ -173,7 +173,7 @@ end
---
---@since 15
---@param path string Filepath (or other identity string).
---@param opts? table
---@param opts? { maxlen?: integer } #
--- - maxlen: (integer, default: 180) Max length (bytes) of the result.
---@return string # Filesystem-safe, mnemonic slug.
function M.slug(path, opts)

View File

@@ -64,13 +64,11 @@
--- -- { "a", "b" }
--- ```
-- LuaLS cannot model the variadic EmmyLua generics used by this module.
---@diagnostic disable: no-unknown, undefined-doc-name, luadoc-miss-symbol, missing-return, missing-return-value, param-type-mismatch, return-type-mismatch, redundant-return-value, undefined-field
-- `never` represents an empty tail for single-value iterators.
--- @nodoc
--- @class vim.IterModule
--- @operator call: vim.Iter<any, any...>
--- @overload fun<T>(src: T[]): vim.IterArray<T>
--- @overload fun<T>(src: T[]): vim.IterArray<T, never>
--- @overload fun<K, V>(src: table<K, V>): vim.Iter<K, V>
--- @overload fun(src: table, ...): vim.Iter<any, any...>
--- @overload fun(src: function, ...): vim.Iter<any, any...>
@@ -468,7 +466,7 @@ end
---
---
--- @since 12
--- @overload fun<T>(self: vim.Iter<T>): T[]
--- @overload fun<T>(self: vim.Iter<T, never>): T[]
--- @overload fun<V1, V2, V...>(self: vim.Iter<V1, V2, V...>): [V1, V2, V...][]
--- @return any[]
function Iter:totable()
@@ -486,7 +484,7 @@ function Iter:totable()
end
--- @nodoc
--- @overload fun<T>(self: vim.IterArray<T>): T[]
--- @overload fun<T>(self: vim.IterArray<T, never>): T[]
--- @overload fun<V1, V2, V...>(self: vim.IterArray<V1, V2, V...>): [V1, V2, V...][]
--- @return any[]
function IterArray:totable()
@@ -1250,7 +1248,7 @@ end
--- @generic R1, R...
--- @param src table<R1, R>|fun(s: table, v: any): R1, R... Table or iterator to drain values from
--- @return vim.Iter<R1, R...>
--- @overload fun<T>(src: T[]): vim.IterArray<T>
--- @overload fun<T>(src: T[]): vim.IterArray<T, never>
--- @overload fun<K, V>(src: table<K, V>): vim.Iter<K, V>
--- @private
function Iter.new(src, ...)

View File

@@ -60,7 +60,7 @@ local stats = { find = { total = 0, time = 0, not_found = 0 } }
--- @type table<string, uv.fs_stat.result>?
local fs_stat_cache
--- @type table<string, table<string,vim.loader.ModuleInfo>>
--- @type table<string, table<string,vim.loader.ModuleInfo>?>
local indexed = {}
--- @param path string

View File

@@ -41,7 +41,7 @@
---@field private filename string
---
--- Internal state for the log file handle. `nil` until the file is opened.
---@field private logfile file*?
---@field private logfile file?
---
--- Internal state for the log file open error.
---@field private openerr string?

View File

@@ -48,10 +48,8 @@ local buf_capabilities = {}
local M = {}
M.__index = M
---@generic T : vim.lsp.Capability
---@param self T
---@param bufnr integer
---@return T
---@return self
function M:new(bufnr)
-- `self` in the `new()` function refers to the concrete type (i.e., the metatable).
-- `Class` may be a subtype of `Capability`, as it supports inheritance.

View File

@@ -203,6 +203,7 @@ end
---@return vim.lsp.folding_range.State
function State:new(bufnr)
self = Capability.new(self, bufnr)
---@cast self vim.lsp.folding_range.State
self.lang = vim.treesitter.language.get_lang(vim.bo[self.bufnr].filetype)
self.row_level = {}
self.row_kinds = {}
@@ -325,8 +326,8 @@ end
--- Split `line` into highlighted virt_text chunks from `spans`.
---
---@param line string
---@param spans [integer, integer, string][] [start_col, end_col, highlight]
---@return [string, string[]?][] [text, highlight[]?][]
---@param spans [integer, integer, string][] # [start_col, end_col, highlight]
---@return [string, string[]?][] # [text, highlight[]?][]
local function spans_to_virt_text(line, spans)
local boundaries = { 0, #line }
for _, span in ipairs(spans) do

View File

@@ -174,7 +174,7 @@ local G = P({
--- @param input string
--- @return vim.snippet.Node<vim.snippet.SnippetData>
function M.parse(input)
return assert(G:match(input), 'snippet parsing failed')
return (assert(G:match(input), 'snippet parsing failed'))
end
return M

View File

@@ -675,14 +675,15 @@ function M.format(opts)
return util.make_given_range_params(r.start, r['end'], bufnr, client.offset_encoding).range
end
local ret = params --[[@as lsp.DocumentFormattingParams|lsp.DocumentRangeFormattingParams|lsp.DocumentRangesFormattingParams]]
--- @type lsp.DocumentFormattingParams|lsp.DocumentRangeFormattingParams|lsp.DocumentRangesFormattingParams
local ret = params
if passed_multiple_ranges then
--- @cast range {start:[integer,integer],end:[integer, integer]}[]
ret = params --[[@as lsp.DocumentRangesFormattingParams]]
--- @cast ret lsp.DocumentRangesFormattingParams
ret.ranges = vim.tbl_map(to_lsp_range, range)
elseif range then
--- @cast range {start:[integer,integer],end:[integer, integer]}
ret = params --[[@as lsp.DocumentRangeFormattingParams]]
--- @cast ret lsp.DocumentRangeFormattingParams
ret.range = to_lsp_range(range)
end
return ret

View File

@@ -416,7 +416,7 @@ function Client.create(config)
local id = client_index
local name = get_name(id, config)
--- @class vim.lsp.Client
--- @type vim.lsp.Client
local self = {
id = id,
config = config,

View File

@@ -428,7 +428,7 @@ end
--- |lsp-handler| for the method `workspace/codeLens/refresh`
---
---@private
---@internal
---@type lsp.Handler
function M.on_refresh(err, _, ctx)
if err then

View File

@@ -511,7 +511,7 @@ function M._lsp_to_complete_items(
return {}
end
---@type fun(item: lsp.CompletionItem, item_prefix: string):boolean
---@type fun(item: lsp.CompletionItem, item_prefix: string): boolean, integer?
local matches
if not prefix:find('%w') then
matches = function(_, _)

View File

@@ -384,7 +384,7 @@ end
--- |lsp-handler| for the method `workspace/diagnostic/refresh`
---@param ctx lsp.HandlerContext
---@private
---@internal
function M.on_refresh(err, _, ctx)
if err then
return vim.NIL

View File

@@ -137,7 +137,7 @@ end
--- Store hints for a specific buffer and client
---@param result lsp.InlayHint[]?
---@param ctx lsp.HandlerContext
---@private
---@internal
function M.on_inlayhint(err, result, ctx)
local bufnr = assert(ctx.bufnr)
local provider = InlayHint.active[bufnr]
@@ -223,7 +223,7 @@ end
--- |lsp-handler| for the method `workspace/inlayHint/refresh`
---@param ctx lsp.HandlerContext
---@private
---@internal
function M.on_refresh(err, _, ctx)
if err then
return vim.NIL

View File

@@ -42,7 +42,7 @@ M._self = log
--- Returns the log filename.
---@return string log filename
function M.get_filename()
---@diagnostic disable-next-line: invisible
---@diagnostic disable-next-line: access-invisible
return log.filename
end

View File

@@ -8,6 +8,7 @@ local uv = vim.uv
local M = {}
--- @param border string|(string|[string,string])[]
--- @return never
local function border_error(border)
error(
string.format(
@@ -69,8 +70,7 @@ local function get_border_size(opts)
-- border specified as a list of border characters
return e
end
--- @diagnostic disable-next-line:missing-return
border_error(border)
return border_error(border)
end
--- @param e string
@@ -179,7 +179,7 @@ function M.apply_text_edits(text_edits, bufnr, position_encoding, change_annotat
local function apply_text_edits()
-- Fix reversed range and indexing each text_edits
for index, text_edit in ipairs(text_edits) do
--- @cast text_edit lsp.TextEdit|{_index: integer}
--- @cast text_edit lsp.TextEdit & { _index?: integer }
-- XXX: Preserve existing _index to avoid surprises if the same edit is reapplied. #39344
if text_edit._index == nil then
text_edit._index = index
@@ -668,7 +668,11 @@ function M.convert_signature_help_to_markdown_lines(signature_help, ft, triggers
if active_signature >= #signature_help.signatures or active_signature < 0 then
active_signature = 0
end
local signature = vim.deepcopy(signature_help.signatures[active_signature + 1])
local signature = signature_help.signatures[active_signature + 1]
if not signature then
return
end
signature = vim.deepcopy(signature)
local label = signature.label
if ft then
-- wrap inside a code block for proper rendering
@@ -1410,7 +1414,7 @@ function M._make_floating_popup_size(contents, opts)
local title_length = 0
local chunks = type(opts.title) == 'string' and { { opts.title } } or opts.title or {}
for _, chunk in
ipairs(chunks --[=[@as [string, string][]]=])
ipairs(chunks --[=[@as [string, string][] ]=])
do
title_length = title_length + vim.fn.strdisplaywidth(chunk[1])
end

View File

@@ -3,7 +3,7 @@ local strbuffer = require('vim._core.stringbuffer')
--- Interface for transport implementations.
---
--- @class (private, exact) vim.net.Transport
--- @class (internal, exact) vim.net.Transport
--- @field listen fun(self: vim.net.Transport, on_read: fun(err: any, data: string), on_exit: fun(code: integer, signal: integer))
--- @field write fun(self: vim.net.Transport, msg: string)
--- @field is_closing fun(self: vim.net.Transport): boolean
@@ -101,7 +101,7 @@ end
--- These messages are buffered in `msgbuf`.
--- @field private connected boolean
--- @field private closing boolean
--- @field private msgbuf vim.Ringbuf
--- @field private msgbuf vim.Ringbuf<string>
--- @field private on_exit? fun(code: integer, signal: integer)
--- @field new fun(host_or_path: string, port?: integer, log: vim.Log): vim.net.TransportConnect
local TransportConnect = {}
@@ -193,7 +193,7 @@ end
--- `nil` means it needs more transport data.
--- decoder errors are reported through `on_error`.
---
---@class (private, exact) vim.net.MessageStream
---@class (internal, exact) vim.net.MessageStream
---@field private strbuf string.buffer
---@field private decode fun(strbuf: string.buffer): string?
---@field private on_read fun(err: string?, data: string?)

View File

@@ -113,7 +113,8 @@ function M.new(...)
if start.buf ~= end_.buf then
error('start and end positions must belong to the same buffer')
end
start_row, start_col, end_row, end_col, buf = start[1], start[2], end_[1], end_[2], start.buf
start_row, start_col, end_row, end_col, buf =
start.row, start.col, end_.row, end_.col, start.buf
elseif nargs == 5 then
---@type integer, integer, integer, integer, integer
buf, start_row, start_col, end_row, end_col = ...
@@ -225,8 +226,8 @@ function M.has(outer, inner)
if getmetatable(inner) == vim.pos then
---@cast inner -vim.Range
return util.cmp_pos.le(outer[1], outer[2], inner[1], inner[2])
and util.cmp_pos.ge(outer[3], outer[4], inner[1], inner[2])
return util.cmp_pos.le(outer[1], outer[2], inner.row, inner.col)
and util.cmp_pos.ge(outer[3], outer[4], inner.row, inner.col)
end
---@cast inner -vim.Pos

View File

@@ -314,6 +314,8 @@ end
---@return integer
---@package
function TSTreeView:iter()
-- TODO(lewis6991): EmmyLua 0.25.1's ipairs annotation omits the table and initial index.
--- @diagnostic disable-next-line: missing-return-value
return ipairs(self.opts.anon and self.nodes or self.named)
end

View File

@@ -109,7 +109,7 @@ local TSCallbackNames = {
---@field private _num_valid_regions integer Number of valid regions
---@field private _is_entirely_valid boolean Whether the entire tree (excluding children) is valid.
---@field private _logger? fun(logtype: string, msg: string)
---@field private _logfile? file*
---@field private _logfile? file
local LanguageTree = {}
---Optional arguments:
@@ -139,7 +139,7 @@ function LanguageTree.new(source, lang, opts)
local injections = opts.injections or {}
--- @class vim.treesitter.LanguageTree
--- @type vim.treesitter.LanguageTree
local self = {
_source = source,
_lang = lang,

View File

@@ -548,6 +548,7 @@ local predicate_handlers = {
return impl['contains'](match, source, predicate, true)
end,
--- @param predicate any[] & { string_set?: table<string, boolean> }
['any-of?'] = function(match, _, source, predicate)
local nodes = match[predicate[2]]
if not nodes or #nodes == 0 then
@@ -559,7 +560,7 @@ local predicate_handlers = {
-- Since 'predicate' will not be used by callers of this function, use it
-- to store a string set built from the list of words to check against.
local string_set = predicate['string_set'] --- @type table<string, boolean>
local string_set = predicate['string_set']
if not string_set then
string_set = {}
for i = 3, #predicate do

View File

@@ -10,7 +10,7 @@ local M = {}
---
---@param payload string Sequence to send via nvim_ui_send(). Use empty string ('') to just register
--- a listener (no sending).
---@param opts? { timeout?: integer, on_timeout?: fun(), group?: integer|string, chan?: integer }
---@param opts? { timeout?: integer, on_timeout?: fun(), group?: integer|string, chan?: integer } #
--- - `timeout` (default: 1000) ms to wait before giving up, or 0 for never (caller must remove the autocmd).
--- - `on_timeout` optional fn called when the timeout fires.
--- - `group`: augroup for the TermResponse autocmd.