aboutsummaryrefslogtreecommitdiff
path: root/src/gen
diff options
context:
space:
mode:
authorLewis Russell <lewis6991@gmail.com>2025-02-26 11:38:07 +0000
committerLewis Russell <me@lewisr.dev>2025-02-26 16:54:37 +0000
commit0f24b0826a27b7868a3aacc25199787e7453d4cc (patch)
tree49585aac252581a735577f2e5711201a85ab8a7e /src/gen
parent85caaa70d44b7b18c633aa0b140de5f3f6d3eee7 (diff)
downloadrneovim-0f24b0826a27b7868a3aacc25199787e7453d4cc.tar.gz
rneovim-0f24b0826a27b7868a3aacc25199787e7453d4cc.tar.bz2
rneovim-0f24b0826a27b7868a3aacc25199787e7453d4cc.zip
build: move all generator scripts to `src/gen/`
- Move all generator Lua scripts to the `src/gen/` - Add a `.luarc.json` to `src/gen/` - Add a `preload.lua` to `src/gen/` - Add `src` to `package.path` so it aligns with `.luarc.json' - Fix all `require` statements in `src/gen/` so they are consistent: - `require('scripts.foo')` -> `require('gen.foo')` - `require('src.nvim.options')` -> `require('nvim.options')` - `require('api.dispatch_deprecated')` -> `require('nvim.api.dispatch_deprecated')`
Diffstat (limited to 'src/gen')
-rw-r--r--src/gen/c_grammar.lua300
-rw-r--r--src/gen/cdoc_grammar.lua87
-rw-r--r--src/gen/cdoc_parser.lua223
-rw-r--r--src/gen/dump_bin_array.lua17
-rw-r--r--src/gen/gen_api_dispatch.lua990
-rw-r--r--src/gen/gen_api_ui_events.lua219
-rw-r--r--src/gen/gen_char_blob.lua96
-rw-r--r--src/gen/gen_declarations.lua186
-rw-r--r--src/gen/gen_eval.lua112
-rwxr-xr-xsrc/gen/gen_eval_files.lua1090
-rw-r--r--src/gen/gen_events.lua42
-rw-r--r--src/gen/gen_ex_cmds.lua194
-rw-r--r--src/gen/gen_filetype.lua209
-rw-r--r--src/gen/gen_help_html.lua1491
-rw-r--r--src/gen/gen_lsp.lua514
-rw-r--r--src/gen/gen_options.lua535
-rwxr-xr-xsrc/gen/gen_vimdoc.lua1041
-rw-r--r--src/gen/gen_vimvim.lua156
-rw-r--r--src/gen/hashy.lua145
-rw-r--r--src/gen/luacats_grammar.lua207
-rw-r--r--src/gen/luacats_parser.lua535
-rw-r--r--src/gen/nvim_version.lua.in9
-rw-r--r--src/gen/preload.lua6
-rw-r--r--src/gen/preload_nlua.lua17
-rw-r--r--src/gen/util.lua399
25 files changed, 8820 insertions, 0 deletions
diff --git a/src/gen/c_grammar.lua b/src/gen/c_grammar.lua
new file mode 100644
index 0000000000..890c260843
--- /dev/null
+++ b/src/gen/c_grammar.lua
@@ -0,0 +1,300 @@
+-- lpeg grammar for building api metadata from a set of header files. It
+-- ignores comments and preprocessor commands and parses a very small subset
+-- of C prototypes with a limited set of types
+
+--- @class nvim.c_grammar.Proto
+--- @field [1] 'proto'
+--- @field pos integer
+--- @field endpos integer
+--- @field fast boolean
+--- @field name string
+--- @field return_type string
+--- @field parameters [string, string][]
+--- @field static true?
+--- @field inline true?
+
+--- @class nvim.c_grammar.Preproc
+--- @field [1] 'preproc'
+--- @field content string
+
+--- @class nvim.c_grammar.Empty
+--- @field [1] 'empty'
+
+--- @alias nvim.c_grammar.result
+--- | nvim.c_grammar.Proto
+--- | nvim.c_grammar.Preproc
+--- | nvim.c_grammar.Empty
+
+--- @class nvim.c_grammar
+--- @field match fun(self, input: string): nvim.c_grammar.result[]
+
+local lpeg = vim.lpeg
+
+local P, R, S, V = lpeg.P, lpeg.R, lpeg.S, lpeg.V
+local C, Ct, Cc, Cg, Cp = lpeg.C, lpeg.Ct, lpeg.Cc, lpeg.Cg, lpeg.Cp
+
+--- @param pat vim.lpeg.Pattern
+local function rep(pat)
+ return pat ^ 0
+end
+
+--- @param pat vim.lpeg.Pattern
+local function rep1(pat)
+ return pat ^ 1
+end
+
+--- @param pat vim.lpeg.Pattern
+local function opt(pat)
+ return pat ^ -1
+end
+
+local any = P(1)
+local letter = R('az', 'AZ') + S('_$')
+local num = R('09')
+local alpha = letter + num
+local nl = P('\r\n') + P('\n')
+local space = S(' \t')
+local str = P('"') * rep((P('\\') * any) + (1 - P('"'))) * P('"')
+local char = P("'") * (any - P("'")) * P("'")
+local ws = space + nl
+local wb = #-alpha -- word boundary
+local id = letter * rep(alpha)
+
+local comment_inline = P('/*') * rep(1 - P('*/')) * P('*/')
+local comment = P('//') * rep(1 - nl) * nl
+local preproc = Ct(Cc('preproc') * P('#') * Cg(rep(1 - nl) * nl, 'content'))
+
+local fill = rep(ws + comment_inline + comment + preproc)
+
+--- @param s string
+--- @return vim.lpeg.Pattern
+local function word(s)
+ return fill * P(s) * wb * fill
+end
+
+--- @param x vim.lpeg.Pattern
+local function comma1(x)
+ return x * rep(fill * P(',') * fill * x)
+end
+
+--- @param v string
+local function Pf(v)
+ return fill * P(v) * fill
+end
+
+--- @param x vim.lpeg.Pattern
+local function paren(x)
+ return P('(') * fill * x * fill * P(')')
+end
+
+local cdoc_comment = P('///') * opt(Ct(Cg(rep(space) * rep(1 - nl), 'comment')))
+
+local braces = P({
+ 'S',
+ A = comment_inline + comment + preproc + str + char + (any - S('{}')),
+ S = P('{') * rep(V('A')) * rep(V('S') + V('A')) * P('}'),
+})
+
+-- stylua: ignore start
+local typed_container = P({
+ 'S',
+ S = (
+ (P('Union') * paren(comma1(V('ID'))))
+ + (P('ArrayOf') * paren(id * opt(P(',') * fill * rep1(num))))
+ + (P('DictOf') * paren(id))
+ + (P('LuaRefOf') * paren(
+ paren(comma1((V('ID') + str) * rep1(ws) * opt(P('*')) * id))
+ * opt(P(',') * fill * opt(P('*')) * V('ID'))
+ ))
+ + (P('Dict') * paren(id))),
+ ID = V('S') + id,
+})
+-- stylua: ignore end
+
+local ptr_mod = word('restrict') + word('__restrict') + word('const')
+local opt_ptr = rep(Pf('*') * opt(ptr_mod))
+
+--- @param name string
+--- @param var string
+--- @return vim.lpeg.Pattern
+local function attr(name, var)
+ return Cg((P(name) * Cc(true)), var)
+end
+
+--- @param name string
+--- @param var string
+--- @return vim.lpeg.Pattern
+local function attr_num(name, var)
+ return Cg((P(name) * paren(C(rep1(num)))), var)
+end
+
+local fattr = (
+ attr_num('FUNC_API_SINCE', 'since')
+ + attr_num('FUNC_API_DEPRECATED_SINCE', 'deprecated_since')
+ + attr('FUNC_API_FAST', 'fast')
+ + attr('FUNC_API_RET_ALLOC', 'ret_alloc')
+ + attr('FUNC_API_NOEXPORT', 'noexport')
+ + attr('FUNC_API_REMOTE_ONLY', 'remote_only')
+ + attr('FUNC_API_LUA_ONLY', 'lua_only')
+ + attr('FUNC_API_TEXTLOCK_ALLOW_CMDWIN', 'textlock_allow_cmdwin')
+ + attr('FUNC_API_TEXTLOCK', 'textlock')
+ + attr('FUNC_API_REMOTE_IMPL', 'remote_impl')
+ + attr('FUNC_API_COMPOSITOR_IMPL', 'compositor_impl')
+ + attr('FUNC_API_CLIENT_IMPL', 'client_impl')
+ + attr('FUNC_API_CLIENT_IGNORE', 'client_ignore')
+ + (P('FUNC_') * rep(alpha) * opt(fill * paren(rep(1 - P(')') * any))))
+)
+
+local void = P('void') * wb
+
+local api_param_type = (
+ (word('Error') * opt_ptr * Cc('error'))
+ + (word('Arena') * opt_ptr * Cc('arena'))
+ + (word('lua_State') * opt_ptr * Cc('lstate'))
+)
+
+local ctype = C(
+ opt(word('const'))
+ * (
+ typed_container
+ -- 'unsigned' is a type modifier, and a type itself
+ + (word('unsigned char') + word('unsigned'))
+ + (word('struct') * fill * id)
+ + id
+ )
+ * opt(word('const'))
+ * opt_ptr
+)
+
+local return_type = (C(void) * fill) + ctype
+
+-- stylua: ignore start
+local params = Ct(
+ (void * #P(')'))
+ + comma1(Ct(
+ (api_param_type + ctype)
+ * fill
+ * C(id)
+ * rep(Pf('[') * rep(alpha) * Pf(']'))
+ * rep(fill * fattr)
+ ))
+ * opt(Pf(',') * P('...'))
+)
+-- stylua: ignore end
+
+local ignore_line = rep1(1 - nl) * nl
+local empty_line = Ct(Cc('empty') * nl * nl)
+
+local proto_name = opt_ptr * fill * id
+
+-- __inline is used in MSVC
+local decl_mod = (
+ Cg(word('static') * Cc(true), 'static')
+ + Cg((word('inline') + word('__inline')) * Cc(true), 'inline')
+)
+
+local proto = Ct(
+ Cg(Cp(), 'pos')
+ * Cc('proto')
+ * -#P('typedef')
+ * #alpha
+ * opt(P('DLLEXPORT') * rep1(ws))
+ * rep(decl_mod)
+ * Cg(return_type, 'return_type')
+ * fill
+ * Cg(proto_name, 'name')
+ * fill
+ * paren(Cg(params, 'parameters'))
+ * Cg(Cc(false), 'fast')
+ * rep(fill * fattr)
+ * Cg(Cp(), 'endpos')
+ * (fill * (S(';') + braces))
+)
+
+local keyset_field = Ct(
+ Cg(ctype, 'type')
+ * fill
+ * Cg(id, 'name')
+ * fill
+ * opt(P('DictKey') * paren(Cg(rep1(1 - P(')')), 'dict_key')))
+ * Pf(';')
+)
+
+local keyset = Ct(
+ P('typedef')
+ * word('struct')
+ * Pf('{')
+ * Cg(Ct(rep1(keyset_field)), 'fields')
+ * Pf('}')
+ * P('Dict')
+ * paren(Cg(id, 'keyset_name'))
+ * Pf(';')
+)
+
+local grammar =
+ Ct(rep1(empty_line + proto + cdoc_comment + comment + preproc + ws + keyset + ignore_line))
+
+if arg[1] == '--test' then
+ for i, t in ipairs({
+ 'void multiqueue_put_event(MultiQueue *self, Event event) {} ',
+ 'void *xmalloc(size_t size) {} ',
+ {
+ 'struct tm *os_localtime_r(const time_t *restrict clock,',
+ ' struct tm *restrict result) FUNC_ATTR_NONNULL_ALL {}',
+ },
+ {
+ '_Bool',
+ '# 163 "src/nvim/event/multiqueue.c"',
+ ' multiqueue_empty(MultiQueue *self)',
+ '{}',
+ },
+ 'const char *find_option_end(const char *arg, OptIndex *opt_idxp) {}',
+ 'bool semsg(const char *const fmt, ...) {}',
+ 'int32_t utf_ptr2CharInfo_impl(uint8_t const *p, uintptr_t const len) {}',
+ 'void ex_argdedupe(exarg_T *eap FUNC_ATTR_UNUSED) {}',
+ 'static TermKeySym register_c0(TermKey *tk, TermKeySym sym, unsigned char ctrl, const char *name) {}',
+ 'unsigned get_bkc_flags(buf_T *buf) {}',
+ 'char *xstpcpy(char *restrict dst, const char *restrict src) {}',
+ 'bool try_leave(const TryState *const tstate, Error *const err) {}',
+ 'void api_set_error(ErrorType errType) {}',
+ {
+ 'void nvim_subscribe(uint64_t channel_id, String event)',
+ 'FUNC_API_SINCE(1) FUNC_API_DEPRECATED_SINCE(13) FUNC_API_REMOTE_ONLY',
+ '{}',
+ },
+
+ -- Do not consume leading preproc statements
+ {
+ '#line 1 "D:/a/neovim/neovim/src\\nvim/mark.h"',
+ 'static __inline int mark_global_index(const char name)',
+ ' FUNC_ATTR_CONST',
+ '{}',
+ },
+ {
+ '',
+ '#line 1 "D:/a/neovim/neovim/src\\nvim/mark.h"',
+ 'static __inline int mark_global_index(const char name)',
+ '{}',
+ },
+ {
+ 'size_t xstrlcpy(char *__restrict dst, const char *__restrict src, size_t dsize)',
+ ' FUNC_ATTR_NONNULL_ALL',
+ ' {}',
+ },
+ }) do
+ if type(t) == 'table' then
+ t = table.concat(t, '\n') .. '\n'
+ end
+ t = t:gsub(' +', ' ')
+ local r = grammar:match(t)
+ if not r then
+ print('Test ' .. i .. ' failed')
+ print(' |' .. table.concat(vim.split(t, '\n'), '\n |'))
+ end
+ end
+end
+
+return {
+ grammar = grammar --[[@as nvim.c_grammar]],
+ typed_container = typed_container,
+}
diff --git a/src/gen/cdoc_grammar.lua b/src/gen/cdoc_grammar.lua
new file mode 100644
index 0000000000..6a7610883b
--- /dev/null
+++ b/src/gen/cdoc_grammar.lua
@@ -0,0 +1,87 @@
+--[[!
+LPEG grammar for C doc comments
+]]
+
+--- @class nvim.cdoc.Param
+--- @field kind 'param'
+--- @field name string
+--- @field desc? string
+
+--- @class nvim.cdoc.Return
+--- @field kind 'return'
+--- @field desc string
+
+--- @class nvim.cdoc.Note
+--- @field desc? string
+
+--- @alias nvim.cdoc.grammar.result
+--- | nvim.cdoc.Param
+--- | nvim.cdoc.Return
+--- | nvim.cdoc.Note
+
+--- @class nvim.cdoc.grammar
+--- @field match fun(self, input: string): nvim.cdoc.grammar.result?
+
+local lpeg = vim.lpeg
+local P, R, S = lpeg.P, lpeg.R, lpeg.S
+local Ct, Cg = lpeg.Ct, lpeg.Cg
+
+--- @param x vim.lpeg.Pattern
+local function rep(x)
+ return x ^ 0
+end
+
+--- @param x vim.lpeg.Pattern
+local function rep1(x)
+ return x ^ 1
+end
+
+--- @param x vim.lpeg.Pattern
+local function opt(x)
+ return x ^ -1
+end
+
+local nl = P('\r\n') + P('\n')
+local ws = rep1(S(' \t') + nl)
+
+local any = P(1) -- (consume one character)
+local letter = R('az', 'AZ') + S('_$')
+local ident = letter * rep(letter + R('09'))
+
+local io = P('[') * (P('in') + P('out') + P('inout')) * P(']')
+
+--- @param x string
+local function Pf(x)
+ return opt(ws) * P(x) * opt(ws)
+end
+
+--- @type table<string,vim.lpeg.Pattern>
+local v = setmetatable({}, {
+ __index = function(_, k)
+ return lpeg.V(k)
+ end,
+})
+
+local grammar = P {
+ rep1(P('@') * v.ats),
+
+ ats = v.at_param + v.at_return + v.at_deprecated + v.at_see + v.at_brief + v.at_note + v.at_nodoc,
+
+ at_param = Ct(
+ Cg(P('param'), 'kind') * opt(io) * ws * Cg(ident, 'name') * opt(ws * Cg(rep(any), 'desc'))
+ ),
+
+ at_return = Ct(Cg(P('return'), 'kind') * opt(S('s')) * opt(ws * Cg(rep(any), 'desc'))),
+
+ at_deprecated = Ct(Cg(P('deprecated'), 'kind')),
+
+ at_see = Ct(Cg(P('see'), 'kind') * ws * opt(Pf('#')) * Cg(rep(any), 'desc')),
+
+ at_brief = Ct(Cg(P('brief'), 'kind') * ws * Cg(rep(any), 'desc')),
+
+ at_note = Ct(Cg(P('note'), 'kind') * ws * Cg(rep(any), 'desc')),
+
+ at_nodoc = Ct(Cg(P('nodoc'), 'kind')),
+}
+
+return grammar --[[@as nvim.cdoc.grammar]]
diff --git a/src/gen/cdoc_parser.lua b/src/gen/cdoc_parser.lua
new file mode 100644
index 0000000000..38314c0efd
--- /dev/null
+++ b/src/gen/cdoc_parser.lua
@@ -0,0 +1,223 @@
+local cdoc_grammar = require('gen.cdoc_grammar')
+local c_grammar = require('gen.c_grammar')
+
+--- @class nvim.cdoc.parser.param
+--- @field name string
+--- @field type string
+--- @field desc string
+
+--- @class nvim.cdoc.parser.return
+--- @field name string
+--- @field type string
+--- @field desc string
+
+--- @class nvim.cdoc.parser.note
+--- @field desc string
+
+--- @class nvim.cdoc.parser.brief
+--- @field kind 'brief'
+--- @field desc string
+
+--- @class nvim.cdoc.parser.fun
+--- @field name string
+--- @field params nvim.cdoc.parser.param[]
+--- @field returns nvim.cdoc.parser.return[]
+--- @field desc string
+--- @field deprecated? true
+--- @field since? string
+--- @field attrs? string[]
+--- @field nodoc? true
+--- @field notes? nvim.cdoc.parser.note[]
+--- @field see? nvim.cdoc.parser.note[]
+
+--- @class nvim.cdoc.parser.State
+--- @field doc_lines? string[]
+--- @field cur_obj? nvim.cdoc.parser.obj
+--- @field last_doc_item? nvim.cdoc.parser.param|nvim.cdoc.parser.return|nvim.cdoc.parser.note
+--- @field last_doc_item_indent? integer
+
+--- @alias nvim.cdoc.parser.obj
+--- | nvim.cdoc.parser.fun
+--- | nvim.cdoc.parser.brief
+
+--- If we collected any `---` lines. Add them to the existing (or new) object
+--- Used for function/class descriptions and multiline param descriptions.
+--- @param state nvim.cdoc.parser.State
+local function add_doc_lines_to_obj(state)
+ if state.doc_lines then
+ state.cur_obj = state.cur_obj or {}
+ local cur_obj = assert(state.cur_obj)
+ local txt = table.concat(state.doc_lines, '\n')
+ if cur_obj.desc then
+ cur_obj.desc = cur_obj.desc .. '\n' .. txt
+ else
+ cur_obj.desc = txt
+ end
+ state.doc_lines = nil
+ end
+end
+
+--- @param line string
+--- @param state nvim.cdoc.parser.State
+local function process_doc_line(line, state)
+ line = line:gsub('^%s+@', '@')
+
+ local parsed = cdoc_grammar:match(line)
+
+ if not parsed then
+ if line:match('^ ') then
+ line = line:sub(2)
+ end
+
+ if state.last_doc_item then
+ if not state.last_doc_item_indent then
+ state.last_doc_item_indent = #line:match('^%s*') + 1
+ end
+ state.last_doc_item.desc = (state.last_doc_item.desc or '')
+ .. '\n'
+ .. line:sub(state.last_doc_item_indent or 1)
+ else
+ state.doc_lines = state.doc_lines or {}
+ table.insert(state.doc_lines, line)
+ end
+ return
+ end
+
+ state.last_doc_item_indent = nil
+ state.last_doc_item = nil
+
+ local kind = parsed.kind
+
+ state.cur_obj = state.cur_obj or {}
+ local cur_obj = assert(state.cur_obj)
+
+ if kind == 'brief' then
+ state.cur_obj = {
+ kind = 'brief',
+ desc = parsed.desc,
+ }
+ elseif kind == 'param' then
+ state.last_doc_item_indent = nil
+ cur_obj.params = cur_obj.params or {}
+ state.last_doc_item = {
+ name = parsed.name,
+ desc = parsed.desc,
+ }
+ table.insert(cur_obj.params, state.last_doc_item)
+ elseif kind == 'return' then
+ cur_obj.returns = { {
+ desc = parsed.desc,
+ } }
+ state.last_doc_item_indent = nil
+ state.last_doc_item = cur_obj.returns[1]
+ elseif kind == 'deprecated' then
+ cur_obj.deprecated = true
+ elseif kind == 'nodoc' then
+ cur_obj.nodoc = true
+ elseif kind == 'since' then
+ cur_obj.since = parsed.desc
+ elseif kind == 'see' then
+ cur_obj.see = cur_obj.see or {}
+ table.insert(cur_obj.see, { desc = parsed.desc })
+ elseif kind == 'note' then
+ state.last_doc_item_indent = nil
+ state.last_doc_item = {
+ desc = parsed.desc,
+ }
+ cur_obj.notes = cur_obj.notes or {}
+ table.insert(cur_obj.notes, state.last_doc_item)
+ else
+ error('Unhandled' .. vim.inspect(parsed))
+ end
+end
+
+--- @param item table
+--- @param state nvim.cdoc.parser.State
+local function process_proto(item, state)
+ state.cur_obj = state.cur_obj or {}
+ local cur_obj = assert(state.cur_obj)
+ cur_obj.name = item.name
+ cur_obj.params = cur_obj.params or {}
+
+ for _, p in ipairs(item.parameters) do
+ local param = { name = p[2], type = p[1] }
+ local added = false
+ for _, cp in ipairs(cur_obj.params) do
+ if cp.name == param.name then
+ cp.type = param.type
+ added = true
+ break
+ end
+ end
+
+ if not added then
+ table.insert(cur_obj.params, param)
+ end
+ end
+
+ cur_obj.returns = cur_obj.returns or { {} }
+ cur_obj.returns[1].type = item.return_type
+
+ for _, a in ipairs({
+ 'fast',
+ 'remote_only',
+ 'lua_only',
+ 'textlock',
+ 'textlock_allow_cmdwin',
+ }) do
+ if item[a] then
+ cur_obj.attrs = cur_obj.attrs or {}
+ table.insert(cur_obj.attrs, a)
+ end
+ end
+
+ cur_obj.deprecated_since = item.deprecated_since
+
+ -- Remove some arguments
+ for i = #cur_obj.params, 1, -1 do
+ local p = cur_obj.params[i]
+ if p.name == 'channel_id' or vim.tbl_contains({ 'lstate', 'arena', 'error' }, p.type) then
+ table.remove(cur_obj.params, i)
+ end
+ end
+end
+
+local M = {}
+
+--- @param filename string
+--- @return {} classes
+--- @return nvim.cdoc.parser.fun[] funs
+--- @return string[] briefs
+function M.parse(filename)
+ local funs = {} --- @type nvim.cdoc.parser.fun[]
+ local briefs = {} --- @type string[]
+ local state = {} --- @type nvim.cdoc.parser.State
+
+ local txt = assert(io.open(filename, 'r')):read('*all')
+
+ local parsed = c_grammar.grammar:match(txt)
+ for _, item in ipairs(parsed) do
+ if item.comment then
+ process_doc_line(item.comment, state)
+ else
+ add_doc_lines_to_obj(state)
+ if item[1] == 'proto' then
+ process_proto(item, state)
+ table.insert(funs, state.cur_obj)
+ end
+ local cur_obj = state.cur_obj
+ if cur_obj and not item.static then
+ if cur_obj.kind == 'brief' then
+ table.insert(briefs, cur_obj.desc)
+ end
+ end
+ state = {}
+ end
+ end
+
+ return {}, funs, briefs
+end
+
+-- M.parse('src/nvim/api/vim.c')
+
+return M
diff --git a/src/gen/dump_bin_array.lua b/src/gen/dump_bin_array.lua
new file mode 100644
index 0000000000..c6cda25e73
--- /dev/null
+++ b/src/gen/dump_bin_array.lua
@@ -0,0 +1,17 @@
+local function dump_bin_array(output, name, data)
+ output:write([[
+ static const uint8_t ]] .. name .. [[[] = {
+]])
+
+ for i = 1, #data do
+ output:write(string.byte(data, i) .. ', ')
+ if i % 10 == 0 then
+ output:write('\n ')
+ end
+ end
+ output:write([[
+};
+]])
+end
+
+return dump_bin_array
diff --git a/src/gen/gen_api_dispatch.lua b/src/gen/gen_api_dispatch.lua
new file mode 100644
index 0000000000..a5d0890c2f
--- /dev/null
+++ b/src/gen/gen_api_dispatch.lua
@@ -0,0 +1,990 @@
+-- Example (manual) invocation:
+--
+-- make
+-- cp build/nvim_version.lua src/nvim/
+-- cd src/nvim
+-- nvim -l generators/gen_api_dispatch.lua "../../build/src/nvim/auto/api/private/dispatch_wrappers.generated.h" "../../build/src/nvim/auto/api/private/api_metadata.generated.h" "../../build/funcs_metadata.mpack" "../../build/src/nvim/auto/lua_api_c_bindings.generated.h" "../../build/src/nvim/auto/keysets_defs.generated.h" "../../build/ui_metadata.mpack" "../../build/cmake.config/auto/versiondef_git.h" "./api/autocmd.h" "./api/buffer.h" "./api/command.h" "./api/deprecated.h" "./api/extmark.h" "./api/keysets_defs.h" "./api/options.h" "./api/tabpage.h" "./api/ui.h" "./api/vim.h" "./api/vimscript.h" "./api/win_config.h" "./api/window.h" "../../build/include/api/autocmd.h.generated.h" "../../build/include/api/buffer.h.generated.h" "../../build/include/api/command.h.generated.h" "../../build/include/api/deprecated.h.generated.h" "../../build/include/api/extmark.h.generated.h" "../../build/include/api/options.h.generated.h" "../../build/include/api/tabpage.h.generated.h" "../../build/include/api/ui.h.generated.h" "../../build/include/api/vim.h.generated.h" "../../build/include/api/vimscript.h.generated.h" "../../build/include/api/win_config.h.generated.h" "../../build/include/api/window.h.generated.h"
+
+local mpack = vim.mpack
+
+local hashy = require 'gen.hashy'
+
+local pre_args = 7
+assert(#arg >= pre_args)
+-- output h file with generated dispatch functions (dispatch_wrappers.generated.h)
+local dispatch_outputf = arg[1]
+-- output h file with packed metadata (api_metadata.generated.h)
+local api_metadata_outputf = arg[2]
+-- output metadata mpack file, for use by other build scripts (funcs_metadata.mpack)
+local mpack_outputf = arg[3]
+local lua_c_bindings_outputf = arg[4] -- lua_api_c_bindings.generated.c
+local keysets_outputf = arg[5] -- keysets_defs.generated.h
+local ui_metadata_inputf = arg[6] -- ui events metadata
+local git_version_inputf = arg[7] -- git version header
+
+local functions = {}
+
+-- names of all headers relative to the source root (for inclusion in the
+-- generated file)
+local headers = {}
+
+-- set of function names, used to detect duplicates
+local function_names = {}
+
+local c_grammar = require('gen.c_grammar')
+
+local startswith = vim.startswith
+
+local function add_function(fn)
+ local public = startswith(fn.name, 'nvim_') or fn.deprecated_since
+ if public and not fn.noexport then
+ functions[#functions + 1] = fn
+ function_names[fn.name] = true
+ if
+ #fn.parameters >= 2
+ and fn.parameters[2][1] == 'Array'
+ and fn.parameters[2][2] == 'uidata'
+ then
+ -- function receives the "args" as a parameter
+ fn.receives_array_args = true
+ -- remove the args parameter
+ table.remove(fn.parameters, 2)
+ end
+ if #fn.parameters ~= 0 and fn.parameters[1][2] == 'channel_id' then
+ -- this function should receive the channel id
+ fn.receives_channel_id = true
+ -- remove the parameter since it won't be passed by the api client
+ table.remove(fn.parameters, 1)
+ end
+ if #fn.parameters ~= 0 and fn.parameters[#fn.parameters][1] == 'error' then
+ -- function can fail if the last parameter type is 'Error'
+ fn.can_fail = true
+ -- remove the error parameter, msgpack has it's own special field
+ -- for specifying errors
+ fn.parameters[#fn.parameters] = nil
+ end
+ if #fn.parameters ~= 0 and fn.parameters[#fn.parameters][1] == 'lstate' then
+ fn.has_lua_imp = true
+ fn.parameters[#fn.parameters] = nil
+ end
+ if #fn.parameters ~= 0 and fn.parameters[#fn.parameters][1] == 'arena' then
+ fn.receives_arena = true
+ fn.parameters[#fn.parameters] = nil
+ end
+ end
+end
+
+local keysets = {}
+
+local function add_keyset(val)
+ local keys = {}
+ local types = {}
+ local c_names = {}
+ local is_set_name = 'is_set__' .. val.keyset_name .. '_'
+ local has_optional = false
+ for i, field in ipairs(val.fields) do
+ local dict_key = field.dict_key or field.name
+ if field.type ~= 'Object' then
+ types[dict_key] = field.type
+ end
+ if field.name ~= is_set_name and field.type ~= 'OptionalKeys' then
+ table.insert(keys, dict_key)
+ if dict_key ~= field.name then
+ c_names[dict_key] = field.name
+ end
+ else
+ if i > 1 then
+ error("'is_set__{type}_' must be first if present")
+ elseif field.name ~= is_set_name then
+ error(val.keyset_name .. ': name of first key should be ' .. is_set_name)
+ elseif field.type ~= 'OptionalKeys' then
+ error("'" .. is_set_name .. "' must have type 'OptionalKeys'")
+ end
+ has_optional = true
+ end
+ end
+ table.insert(keysets, {
+ name = val.keyset_name,
+ keys = keys,
+ c_names = c_names,
+ types = types,
+ has_optional = has_optional,
+ })
+end
+
+local ui_options_text = nil
+
+-- read each input file, parse and append to the api metadata
+for i = pre_args + 1, #arg do
+ local full_path = arg[i]
+ local parts = {}
+ for part in string.gmatch(full_path, '[^/]+') do
+ parts[#parts + 1] = part
+ end
+ headers[#headers + 1] = parts[#parts - 1] .. '/' .. parts[#parts]
+
+ local input = assert(io.open(full_path, 'rb'))
+
+ local text = input:read('*all')
+ local tmp = c_grammar.grammar:match(text)
+ for j = 1, #tmp do
+ local val = tmp[j]
+ if val.keyset_name then
+ add_keyset(val)
+ elseif val.name then
+ add_function(val)
+ end
+ end
+
+ ui_options_text = ui_options_text or string.match(text, 'ui_ext_names%[][^{]+{([^}]+)}')
+ input:close()
+end
+
+local function shallowcopy(orig)
+ local copy = {}
+ for orig_key, orig_value in pairs(orig) do
+ copy[orig_key] = orig_value
+ end
+ return copy
+end
+
+-- Export functions under older deprecated names.
+-- These will be removed eventually.
+local deprecated_aliases = require('nvim.api.dispatch_deprecated')
+for _, f in ipairs(shallowcopy(functions)) do
+ local ismethod = false
+ if startswith(f.name, 'nvim_') then
+ if startswith(f.name, 'nvim__') or f.name == 'nvim_error_event' then
+ f.since = -1
+ elseif f.since == nil then
+ print('Function ' .. f.name .. ' lacks since field.\n')
+ os.exit(1)
+ end
+ f.since = tonumber(f.since)
+ if f.deprecated_since ~= nil then
+ f.deprecated_since = tonumber(f.deprecated_since)
+ end
+
+ if startswith(f.name, 'nvim_buf_') then
+ ismethod = true
+ elseif startswith(f.name, 'nvim_win_') then
+ ismethod = true
+ elseif startswith(f.name, 'nvim_tabpage_') then
+ ismethod = true
+ end
+ f.remote = f.remote_only or not f.lua_only
+ f.lua = f.lua_only or not f.remote_only
+ f.eval = (not f.lua_only) and not f.remote_only
+ else
+ f.deprecated_since = tonumber(f.deprecated_since)
+ assert(f.deprecated_since == 1)
+ f.remote = true
+ f.since = 0
+ end
+ f.method = ismethod
+ local newname = deprecated_aliases[f.name]
+ if newname ~= nil then
+ if function_names[newname] then
+ -- duplicate
+ print(
+ 'Function '
+ .. f.name
+ .. ' has deprecated alias\n'
+ .. newname
+ .. ' which has a separate implementation.\n'
+ .. 'Please remove it from src/nvim/api/dispatch_deprecated.lua'
+ )
+ os.exit(1)
+ end
+ local newf = shallowcopy(f)
+ newf.name = newname
+ if newname == 'ui_try_resize' then
+ -- The return type was incorrectly set to Object in 0.1.5.
+ -- Keep it that way for clients that rely on this.
+ newf.return_type = 'Object'
+ end
+ newf.impl_name = f.name
+ newf.lua = false
+ newf.eval = false
+ newf.since = 0
+ newf.deprecated_since = 1
+ functions[#functions + 1] = newf
+ end
+end
+
+-- don't expose internal attributes like "impl_name" in public metadata
+local exported_attributes = { 'name', 'return_type', 'method', 'since', 'deprecated_since' }
+local exported_functions = {}
+for _, f in ipairs(functions) do
+ if not (startswith(f.name, 'nvim__') or f.name == 'nvim_error_event' or f.name == 'redraw') then
+ local f_exported = {}
+ for _, attr in ipairs(exported_attributes) do
+ f_exported[attr] = f[attr]
+ end
+ f_exported.parameters = {}
+ for i, param in ipairs(f.parameters) do
+ if param[1] == 'DictOf(LuaRef)' then
+ param = { 'Dict', param[2] }
+ elseif startswith(param[1], 'Dict(') then
+ param = { 'Dict', param[2] }
+ end
+ f_exported.parameters[i] = param
+ end
+ if startswith(f.return_type, 'Dict(') then
+ f_exported.return_type = 'Dict'
+ end
+ exported_functions[#exported_functions + 1] = f_exported
+ end
+end
+
+local ui_options = { 'rgb' }
+for x in string.gmatch(ui_options_text, '"([a-z][a-z_]+)"') do
+ table.insert(ui_options, x)
+end
+
+local version = require 'nvim_version' -- `build/nvim_version.lua` file.
+local git_version = io.open(git_version_inputf):read '*a'
+local version_build = string.match(git_version, '#define NVIM_VERSION_BUILD "([^"]+)"') or vim.NIL
+
+-- serialize the API metadata using msgpack and embed into the resulting
+-- binary for easy querying by clients
+local api_metadata_output = assert(io.open(api_metadata_outputf, 'wb'))
+local pieces = {}
+
+-- Naively using mpack.encode({foo=x, bar=y}) will make the build
+-- "non-reproducible". Emit maps directly as FIXDICT(2) "foo" x "bar" y instead
+local function fixdict(num)
+ if num > 15 then
+ error 'implement more dict codes'
+ end
+ table.insert(pieces, string.char(128 + num))
+end
+local function put(item, item2)
+ table.insert(pieces, mpack.encode(item))
+ if item2 ~= nil then
+ table.insert(pieces, mpack.encode(item2))
+ end
+end
+
+fixdict(6)
+
+put('version')
+fixdict(1 + #version)
+for _, item in ipairs(version) do
+ -- NB: all items are mandatory. But any error will be less confusing
+ -- with placeholder vim.NIL (than invalid mpack data)
+ local val = item[2] == nil and vim.NIL or item[2]
+ put(item[1], val)
+end
+put('build', version_build)
+
+put('functions', exported_functions)
+put('ui_events')
+table.insert(pieces, io.open(ui_metadata_inputf, 'rb'):read('*all'))
+put('ui_options', ui_options)
+
+put('error_types')
+fixdict(2)
+put('Exception', { id = 0 })
+put('Validation', { id = 1 })
+
+put('types')
+local types =
+ { { 'Buffer', 'nvim_buf_' }, { 'Window', 'nvim_win_' }, { 'Tabpage', 'nvim_tabpage_' } }
+fixdict(#types)
+for i, item in ipairs(types) do
+ put(item[1])
+ fixdict(2)
+ put('id', i - 1)
+ put('prefix', item[2])
+end
+
+local packed = table.concat(pieces)
+local dump_bin_array = require('gen.dump_bin_array')
+dump_bin_array(api_metadata_output, 'packed_api_metadata', packed)
+api_metadata_output:close()
+
+-- start building the dispatch wrapper output
+local output = assert(io.open(dispatch_outputf, 'wb'))
+
+local keysets_defs = assert(io.open(keysets_outputf, 'wb'))
+
+-- ===========================================================================
+-- NEW API FILES MUST GO HERE.
+--
+-- When creating a new API file, you must include it here,
+-- so that the dispatcher can find the C functions that you are creating!
+-- ===========================================================================
+output:write([[
+#include "nvim/errors.h"
+#include "nvim/ex_docmd.h"
+#include "nvim/ex_getln.h"
+#include "nvim/globals.h"
+#include "nvim/log.h"
+#include "nvim/map_defs.h"
+
+#include "nvim/api/autocmd.h"
+#include "nvim/api/buffer.h"
+#include "nvim/api/command.h"
+#include "nvim/api/deprecated.h"
+#include "nvim/api/extmark.h"
+#include "nvim/api/options.h"
+#include "nvim/api/tabpage.h"
+#include "nvim/api/ui.h"
+#include "nvim/api/vim.h"
+#include "nvim/api/vimscript.h"
+#include "nvim/api/win_config.h"
+#include "nvim/api/window.h"
+#include "nvim/ui_client.h"
+
+]])
+
+keysets_defs:write('// IWYU pragma: private, include "nvim/api/private/dispatch.h"\n\n')
+
+for _, k in ipairs(keysets) do
+ local neworder, hashfun = hashy.hashy_hash(k.name, k.keys, function(idx)
+ return k.name .. '_table[' .. idx .. '].str'
+ end)
+
+ keysets_defs:write('extern KeySetLink ' .. k.name .. '_table[' .. (1 + #neworder) .. '];\n')
+
+ local function typename(type)
+ if type == 'HLGroupID' then
+ return 'kObjectTypeInteger'
+ elseif not type or vim.startswith(type, 'Union') then
+ return 'kObjectTypeNil'
+ elseif vim.startswith(type, 'LuaRefOf') then
+ return 'kObjectTypeLuaRef'
+ elseif type == 'StringArray' then
+ return 'kUnpackTypeStringArray'
+ elseif vim.startswith(type, 'ArrayOf') then
+ return 'kObjectTypeArray'
+ else
+ return 'kObjectType' .. type
+ end
+ end
+
+ output:write('KeySetLink ' .. k.name .. '_table[] = {\n')
+ for i, key in ipairs(neworder) do
+ local ind = -1
+ if k.has_optional then
+ ind = i
+ keysets_defs:write('#define KEYSET_OPTIDX_' .. k.name .. '__' .. key .. ' ' .. ind .. '\n')
+ end
+ output:write(
+ ' {"'
+ .. key
+ .. '", offsetof(KeyDict_'
+ .. k.name
+ .. ', '
+ .. (k.c_names[key] or key)
+ .. '), '
+ .. typename(k.types[key])
+ .. ', '
+ .. ind
+ .. ', '
+ .. (k.types[key] == 'HLGroupID' and 'true' or 'false')
+ .. '},\n'
+ )
+ end
+ output:write(' {NULL, 0, kObjectTypeNil, -1, false},\n')
+ output:write('};\n\n')
+
+ output:write(hashfun)
+
+ output:write([[
+KeySetLink *KeyDict_]] .. k.name .. [[_get_field(const char *str, size_t len)
+{
+ int hash = ]] .. k.name .. [[_hash(str, len);
+ if (hash == -1) {
+ return NULL;
+ }
+ return &]] .. k.name .. [[_table[hash];
+}
+
+]])
+end
+
+local function real_type(type)
+ local rv = type
+ local rmatch = string.match(type, 'Dict%(([_%w]+)%)')
+ if rmatch then
+ return 'KeyDict_' .. rmatch
+ elseif c_grammar.typed_container:match(rv) then
+ if rv:match('Array') then
+ rv = 'Array'
+ else
+ rv = 'Dict'
+ end
+ end
+ return rv
+end
+
+local function attr_name(rt)
+ if rt == 'Float' then
+ return 'floating'
+ else
+ return rt:lower()
+ end
+end
+
+-- start the handler functions. Visit each function metadata to build the
+-- handler function with code generated for validating arguments and calling to
+-- the real API.
+for i = 1, #functions do
+ local fn = functions[i]
+ if fn.impl_name == nil and fn.remote then
+ local args = {}
+
+ output:write(
+ 'Object handle_' .. fn.name .. '(uint64_t channel_id, Array args, Arena* arena, Error *error)'
+ )
+ output:write('\n{')
+ output:write('\n#ifdef NVIM_LOG_DEBUG')
+ output:write('\n DLOG("RPC: ch %" PRIu64 ": invoke ' .. fn.name .. '", channel_id);')
+ output:write('\n#endif')
+ output:write('\n Object ret = NIL;')
+ -- Declare/initialize variables that will hold converted arguments
+ for j = 1, #fn.parameters do
+ local param = fn.parameters[j]
+ local rt = real_type(param[1])
+ local converted = 'arg_' .. j
+ output:write('\n ' .. rt .. ' ' .. converted .. ';')
+ end
+ output:write('\n')
+ if not fn.receives_array_args then
+ output:write('\n if (args.size != ' .. #fn.parameters .. ') {')
+ output:write(
+ '\n api_set_error(error, kErrorTypeException, \
+ "Wrong number of arguments: expecting '
+ .. #fn.parameters
+ .. ' but got %zu", args.size);'
+ )
+ output:write('\n goto cleanup;')
+ output:write('\n }\n')
+ end
+
+ -- Validation/conversion for each argument
+ for j = 1, #fn.parameters do
+ local converted, param
+ param = fn.parameters[j]
+ converted = 'arg_' .. j
+ local rt = real_type(param[1])
+ if rt == 'Object' then
+ output:write('\n ' .. converted .. ' = args.items[' .. (j - 1) .. '];\n')
+ elseif rt:match('^KeyDict_') then
+ converted = '&' .. converted
+ output:write('\n if (args.items[' .. (j - 1) .. '].type == kObjectTypeDict) {') --luacheck: ignore 631
+ output:write('\n memset(' .. converted .. ', 0, sizeof(*' .. converted .. '));') -- TODO: neeeee
+ output:write(
+ '\n if (!api_dict_to_keydict('
+ .. converted
+ .. ', '
+ .. rt
+ .. '_get_field, args.items['
+ .. (j - 1)
+ .. '].data.dict, error)) {'
+ )
+ output:write('\n goto cleanup;')
+ output:write('\n }')
+ output:write(
+ '\n } else if (args.items['
+ .. (j - 1)
+ .. '].type == kObjectTypeArray && args.items['
+ .. (j - 1)
+ .. '].data.array.size == 0) {'
+ ) --luacheck: ignore 631
+ output:write('\n memset(' .. converted .. ', 0, sizeof(*' .. converted .. '));')
+
+ output:write('\n } else {')
+ output:write(
+ '\n api_set_error(error, kErrorTypeException, \
+ "Wrong type for argument '
+ .. j
+ .. ' when calling '
+ .. fn.name
+ .. ', expecting '
+ .. param[1]
+ .. '");'
+ )
+ output:write('\n goto cleanup;')
+ output:write('\n }\n')
+ else
+ if rt:match('^Buffer$') or rt:match('^Window$') or rt:match('^Tabpage$') then
+ -- Buffer, Window, and Tabpage have a specific type, but are stored in integer
+ output:write(
+ '\n if (args.items['
+ .. (j - 1)
+ .. '].type == kObjectType'
+ .. rt
+ .. ' && args.items['
+ .. (j - 1)
+ .. '].data.integer >= 0) {'
+ )
+ output:write(
+ '\n ' .. converted .. ' = (handle_T)args.items[' .. (j - 1) .. '].data.integer;'
+ )
+ else
+ output:write('\n if (args.items[' .. (j - 1) .. '].type == kObjectType' .. rt .. ') {')
+ output:write(
+ '\n '
+ .. converted
+ .. ' = args.items['
+ .. (j - 1)
+ .. '].data.'
+ .. attr_name(rt)
+ .. ';'
+ )
+ end
+ if
+ rt:match('^Buffer$')
+ or rt:match('^Window$')
+ or rt:match('^Tabpage$')
+ or rt:match('^Boolean$')
+ then
+ -- accept nonnegative integers for Booleans, Buffers, Windows and Tabpages
+ output:write(
+ '\n } else if (args.items['
+ .. (j - 1)
+ .. '].type == kObjectTypeInteger && args.items['
+ .. (j - 1)
+ .. '].data.integer >= 0) {'
+ )
+ output:write(
+ '\n ' .. converted .. ' = (handle_T)args.items[' .. (j - 1) .. '].data.integer;'
+ )
+ end
+ if rt:match('^Float$') then
+ -- accept integers for Floats
+ output:write('\n } else if (args.items[' .. (j - 1) .. '].type == kObjectTypeInteger) {')
+ output:write(
+ '\n ' .. converted .. ' = (Float)args.items[' .. (j - 1) .. '].data.integer;'
+ )
+ end
+ -- accept empty lua tables as empty dictionaries
+ if rt:match('^Dict') then
+ output:write(
+ '\n } else if (args.items['
+ .. (j - 1)
+ .. '].type == kObjectTypeArray && args.items['
+ .. (j - 1)
+ .. '].data.array.size == 0) {'
+ ) --luacheck: ignore 631
+ output:write('\n ' .. converted .. ' = (Dict)ARRAY_DICT_INIT;')
+ end
+ output:write('\n } else {')
+ output:write(
+ '\n api_set_error(error, kErrorTypeException, \
+ "Wrong type for argument '
+ .. j
+ .. ' when calling '
+ .. fn.name
+ .. ', expecting '
+ .. param[1]
+ .. '");'
+ )
+ output:write('\n goto cleanup;')
+ output:write('\n }\n')
+ end
+ args[#args + 1] = converted
+ end
+
+ if fn.textlock then
+ output:write('\n if (text_locked()) {')
+ output:write('\n api_set_error(error, kErrorTypeException, "%s", get_text_locked_msg());')
+ output:write('\n goto cleanup;')
+ output:write('\n }\n')
+ elseif fn.textlock_allow_cmdwin then
+ output:write('\n if (textlock != 0 || expr_map_locked()) {')
+ output:write('\n api_set_error(error, kErrorTypeException, "%s", e_textlock);')
+ output:write('\n goto cleanup;')
+ output:write('\n }\n')
+ end
+
+ -- function call
+ output:write('\n ')
+ if fn.return_type ~= 'void' then
+ -- has a return value, prefix the call with a declaration
+ output:write(fn.return_type .. ' rv = ')
+ end
+
+ -- write the function name and the opening parenthesis
+ output:write(fn.name .. '(')
+
+ local call_args = {}
+ if fn.receives_channel_id then
+ table.insert(call_args, 'channel_id')
+ end
+
+ if fn.receives_array_args then
+ table.insert(call_args, 'args')
+ end
+
+ for _, a in ipairs(args) do
+ table.insert(call_args, a)
+ end
+
+ if fn.receives_arena then
+ table.insert(call_args, 'arena')
+ end
+
+ if fn.has_lua_imp then
+ table.insert(call_args, 'NULL')
+ end
+
+ if fn.can_fail then
+ table.insert(call_args, 'error')
+ end
+
+ output:write(table.concat(call_args, ', '))
+ output:write(');\n')
+
+ if fn.can_fail then
+ -- if the function can fail, also pass a pointer to the local error object
+ -- and check for the error
+ output:write('\n if (ERROR_SET(error)) {')
+ output:write('\n goto cleanup;')
+ output:write('\n }\n')
+ end
+
+ local ret_type = real_type(fn.return_type)
+ if string.match(ret_type, '^KeyDict_') then
+ local table = string.sub(ret_type, 9) .. '_table'
+ output:write(
+ '\n ret = DICT_OBJ(api_keydict_to_dict(&rv, '
+ .. table
+ .. ', ARRAY_SIZE('
+ .. table
+ .. '), arena));'
+ )
+ elseif ret_type ~= 'void' then
+ output:write('\n ret = ' .. string.upper(real_type(fn.return_type)) .. '_OBJ(rv);')
+ end
+ output:write('\n\ncleanup:')
+
+ output:write('\n return ret;\n}\n\n')
+ end
+end
+
+local remote_fns = {}
+for _, fn in ipairs(functions) do
+ if fn.remote then
+ remote_fns[fn.name] = fn
+ end
+end
+remote_fns.redraw = { impl_name = 'ui_client_redraw', fast = true }
+
+local names = vim.tbl_keys(remote_fns)
+table.sort(names)
+local hashorder, hashfun = hashy.hashy_hash('msgpack_rpc_get_handler_for', names, function(idx)
+ return 'method_handlers[' .. idx .. '].name'
+end)
+
+output:write('const MsgpackRpcRequestHandler method_handlers[] = {\n')
+for n, name in ipairs(hashorder) do
+ local fn = remote_fns[name]
+ fn.handler_id = n - 1
+ output:write(
+ ' { .name = "'
+ .. name
+ .. '", .fn = handle_'
+ .. (fn.impl_name or fn.name)
+ .. ', .fast = '
+ .. tostring(fn.fast)
+ .. ', .ret_alloc = '
+ .. tostring(not not fn.ret_alloc)
+ .. '},\n'
+ )
+end
+output:write('};\n\n')
+output:write(hashfun)
+
+output:close()
+
+functions.keysets = keysets
+local mpack_output = assert(io.open(mpack_outputf, 'wb'))
+mpack_output:write(mpack.encode(functions))
+mpack_output:close()
+
+local function include_headers(output_handle, headers_to_include)
+ for i = 1, #headers_to_include do
+ if headers_to_include[i]:sub(-12) ~= '.generated.h' then
+ output_handle:write('\n#include "nvim/' .. headers_to_include[i] .. '"')
+ end
+ end
+end
+
+local function write_shifted_output(str, ...)
+ str = str:gsub('\n ', '\n')
+ str = str:gsub('^ ', '')
+ str = str:gsub(' +$', '')
+ output:write(string.format(str, ...))
+end
+
+-- start building lua output
+output = assert(io.open(lua_c_bindings_outputf, 'wb'))
+
+include_headers(output, headers)
+output:write('\n')
+
+local lua_c_functions = {}
+
+local function process_function(fn)
+ local lua_c_function_name = ('nlua_api_%s'):format(fn.name)
+ write_shifted_output(
+ [[
+
+ static int %s(lua_State *lstate)
+ {
+ Error err = ERROR_INIT;
+ Arena arena = ARENA_EMPTY;
+ char *err_param = 0;
+ if (lua_gettop(lstate) != %i) {
+ api_set_error(&err, kErrorTypeValidation, "Expected %i argument%s");
+ goto exit_0;
+ }
+ ]],
+ lua_c_function_name,
+ #fn.parameters,
+ #fn.parameters,
+ (#fn.parameters == 1) and '' or 's'
+ )
+ lua_c_functions[#lua_c_functions + 1] = {
+ binding = lua_c_function_name,
+ api = fn.name,
+ }
+
+ if not fn.fast then
+ write_shifted_output(
+ [[
+ if (!nlua_is_deferred_safe()) {
+ return luaL_error(lstate, e_fast_api_disabled, "%s");
+ }
+ ]],
+ fn.name
+ )
+ end
+
+ if fn.textlock then
+ write_shifted_output([[
+ if (text_locked()) {
+ api_set_error(&err, kErrorTypeException, "%%s", get_text_locked_msg());
+ goto exit_0;
+ }
+ ]])
+ elseif fn.textlock_allow_cmdwin then
+ write_shifted_output([[
+ if (textlock != 0 || expr_map_locked()) {
+ api_set_error(&err, kErrorTypeException, "%%s", e_textlock);
+ goto exit_0;
+ }
+ ]])
+ end
+
+ local cparams = ''
+ local free_code = {}
+ for j = #fn.parameters, 1, -1 do
+ local param = fn.parameters[j]
+ local cparam = string.format('arg%u', j)
+ local param_type = real_type(param[1])
+ local extra = param_type == 'Dict' and 'false, ' or ''
+ local arg_free_code = ''
+ if param[1] == 'Object' then
+ extra = 'true, '
+ arg_free_code = ' api_luarefs_free_object(' .. cparam .. ');'
+ elseif param[1] == 'DictOf(LuaRef)' then
+ extra = 'true, '
+ arg_free_code = ' api_luarefs_free_dict(' .. cparam .. ');'
+ elseif param[1] == 'LuaRef' then
+ arg_free_code = ' api_free_luaref(' .. cparam .. ');'
+ end
+ local errshift = 0
+ local seterr = ''
+ if string.match(param_type, '^KeyDict_') then
+ write_shifted_output(
+ [[
+ %s %s = KEYDICT_INIT;
+ nlua_pop_keydict(lstate, &%s, %s_get_field, &err_param, &arena, &err);
+ ]],
+ param_type,
+ cparam,
+ cparam,
+ param_type
+ )
+ cparam = '&' .. cparam
+ errshift = 1 -- free incomplete dict on error
+ arg_free_code = ' api_luarefs_free_keydict('
+ .. cparam
+ .. ', '
+ .. string.sub(param_type, 9)
+ .. '_table);'
+ else
+ write_shifted_output(
+ [[
+ const %s %s = nlua_pop_%s(lstate, %s&arena, &err);]],
+ param[1],
+ cparam,
+ param_type,
+ extra
+ )
+ seterr = '\n err_param = "' .. param[2] .. '";'
+ end
+
+ write_shifted_output([[
+
+ if (ERROR_SET(&err)) {]] .. seterr .. [[
+
+ goto exit_%u;
+ }
+
+ ]], #fn.parameters - j + errshift)
+ free_code[#free_code + 1] = arg_free_code
+ cparams = cparam .. ', ' .. cparams
+ end
+ if fn.receives_channel_id then
+ cparams = 'LUA_INTERNAL_CALL, ' .. cparams
+ end
+ if fn.receives_arena then
+ cparams = cparams .. '&arena, '
+ end
+
+ if fn.has_lua_imp then
+ cparams = cparams .. 'lstate, '
+ end
+
+ if fn.can_fail then
+ cparams = cparams .. '&err'
+ else
+ cparams = cparams:gsub(', $', '')
+ end
+ local free_at_exit_code = ''
+ for i = 1, #free_code do
+ local rev_i = #free_code - i + 1
+ local code = free_code[rev_i]
+ if i == 1 and not string.match(real_type(fn.parameters[1][1]), '^KeyDict_') then
+ free_at_exit_code = free_at_exit_code .. ('\n%s'):format(code)
+ else
+ free_at_exit_code = free_at_exit_code .. ('\nexit_%u:\n%s'):format(rev_i, code)
+ end
+ end
+ local err_throw_code = [[
+
+exit_0:
+ arena_mem_free(arena_finish(&arena));
+ if (ERROR_SET(&err)) {
+ luaL_where(lstate, 1);
+ if (err_param) {
+ lua_pushstring(lstate, "Invalid '");
+ lua_pushstring(lstate, err_param);
+ lua_pushstring(lstate, "': ");
+ }
+ lua_pushstring(lstate, err.msg);
+ api_clear_error(&err);
+ lua_concat(lstate, err_param ? 5 : 2);
+ return lua_error(lstate);
+ }
+]]
+ local return_type
+ if fn.return_type ~= 'void' then
+ if fn.return_type:match('^ArrayOf') then
+ return_type = 'Array'
+ else
+ return_type = fn.return_type
+ end
+ local free_retval = ''
+ if fn.ret_alloc then
+ free_retval = ' api_free_' .. return_type:lower() .. '(ret);'
+ end
+ write_shifted_output(' %s ret = %s(%s);\n', fn.return_type, fn.name, cparams)
+
+ local ret_type = real_type(fn.return_type)
+ local ret_mode = (ret_type == 'Object') and '&' or ''
+ if fn.has_lua_imp then
+ -- only push onto the Lua stack if we haven't already
+ write_shifted_output(
+ [[
+ if (lua_gettop(lstate) == 0) {
+ nlua_push_%s(lstate, %sret, kNluaPushSpecial | kNluaPushFreeRefs);
+ }
+ ]],
+ return_type,
+ ret_mode
+ )
+ elseif string.match(ret_type, '^KeyDict_') then
+ write_shifted_output(
+ ' nlua_push_keydict(lstate, &ret, %s_table);\n',
+ string.sub(ret_type, 9)
+ )
+ else
+ local special = (fn.since ~= nil and fn.since < 11)
+ write_shifted_output(
+ ' nlua_push_%s(lstate, %sret, %s | kNluaPushFreeRefs);\n',
+ return_type,
+ ret_mode,
+ special and 'kNluaPushSpecial' or '0'
+ )
+ end
+
+ -- NOTE: we currently assume err_throw needs nothing from arena
+ write_shifted_output(
+ [[
+ %s
+ %s
+ %s
+ return 1;
+ ]],
+ free_retval,
+ free_at_exit_code,
+ err_throw_code
+ )
+ else
+ write_shifted_output(
+ [[
+ %s(%s);
+ %s
+ %s
+ return 0;
+ ]],
+ fn.name,
+ cparams,
+ free_at_exit_code,
+ err_throw_code
+ )
+ end
+ write_shifted_output([[
+ }
+ ]])
+end
+
+for _, fn in ipairs(functions) do
+ if fn.lua or fn.name:sub(1, 4) == '_vim' then
+ process_function(fn)
+ end
+end
+
+output:write(string.format(
+ [[
+void nlua_add_api_functions(lua_State *lstate)
+{
+ lua_createtable(lstate, 0, %u);
+]],
+ #lua_c_functions
+))
+for _, func in ipairs(lua_c_functions) do
+ output:write(string.format(
+ [[
+
+ lua_pushcfunction(lstate, &%s);
+ lua_setfield(lstate, -2, "%s");]],
+ func.binding,
+ func.api
+ ))
+end
+output:write([[
+
+ lua_setfield(lstate, -2, "api");
+}
+]])
+
+output:close()
+keysets_defs:close()
diff --git a/src/gen/gen_api_ui_events.lua b/src/gen/gen_api_ui_events.lua
new file mode 100644
index 0000000000..8ba67dafff
--- /dev/null
+++ b/src/gen/gen_api_ui_events.lua
@@ -0,0 +1,219 @@
+local mpack = vim.mpack
+
+assert(#arg == 5)
+local input = io.open(arg[1], 'rb')
+local call_output = io.open(arg[2], 'wb')
+local remote_output = io.open(arg[3], 'wb')
+local metadata_output = io.open(arg[4], 'wb')
+local client_output = io.open(arg[5], 'wb')
+
+local c_grammar = require('gen.c_grammar')
+local events = c_grammar.grammar:match(input:read('*all'))
+
+local hashy = require 'gen.hashy'
+
+local function write_signature(output, ev, prefix, notype)
+ output:write('(' .. prefix)
+ if prefix == '' and #ev.parameters == 0 then
+ output:write('void')
+ end
+ for j = 1, #ev.parameters do
+ if j > 1 or prefix ~= '' then
+ output:write(', ')
+ end
+ local param = ev.parameters[j]
+ if not notype then
+ output:write(param[1] .. ' ')
+ end
+ output:write(param[2])
+ end
+ output:write(')')
+end
+
+local function write_arglist(output, ev)
+ if #ev.parameters == 0 then
+ return
+ end
+ output:write(' MAXSIZE_TEMP_ARRAY(args, ' .. #ev.parameters .. ');\n')
+ for j = 1, #ev.parameters do
+ local param = ev.parameters[j]
+ local kind = string.upper(param[1])
+ output:write(' ADD_C(args, ')
+ output:write(kind .. '_OBJ(' .. param[2] .. ')')
+ output:write(');\n')
+ end
+end
+
+local function call_ui_event_method(output, ev)
+ output:write('void ui_client_event_' .. ev.name .. '(Array args)\n{\n')
+
+ local hlattrs_args_count = 0
+ if #ev.parameters > 0 then
+ output:write(' if (args.size < ' .. #ev.parameters)
+ for j = 1, #ev.parameters do
+ local kind = ev.parameters[j][1]
+ if kind ~= 'Object' then
+ if kind == 'HlAttrs' then
+ kind = 'Dict'
+ end
+ output:write('\n || args.items[' .. (j - 1) .. '].type != kObjectType' .. kind .. '')
+ end
+ end
+ output:write(') {\n')
+ output:write(' ELOG("Error handling ui event \'' .. ev.name .. '\'");\n')
+ output:write(' return;\n')
+ output:write(' }\n')
+ end
+
+ for j = 1, #ev.parameters do
+ local param = ev.parameters[j]
+ local kind = param[1]
+ output:write(' ' .. kind .. ' arg_' .. j .. ' = ')
+ if kind == 'HlAttrs' then
+ -- The first HlAttrs argument is rgb_attrs and second is cterm_attrs
+ output:write(
+ 'ui_client_dict2hlattrs(args.items['
+ .. (j - 1)
+ .. '].data.dict, '
+ .. (hlattrs_args_count == 0 and 'true' or 'false')
+ .. ');\n'
+ )
+ hlattrs_args_count = hlattrs_args_count + 1
+ elseif kind == 'Object' then
+ output:write('args.items[' .. (j - 1) .. '];\n')
+ elseif kind == 'Window' then
+ output:write('(Window)args.items[' .. (j - 1) .. '].data.integer;\n')
+ else
+ output:write('args.items[' .. (j - 1) .. '].data.' .. string.lower(kind) .. ';\n')
+ end
+ end
+
+ output:write(' tui_' .. ev.name .. '(tui')
+ for j = 1, #ev.parameters do
+ output:write(', arg_' .. j)
+ end
+ output:write(');\n')
+
+ output:write('}\n\n')
+end
+
+events = vim.tbl_filter(function(ev)
+ return ev[1] ~= 'empty' and ev[1] ~= 'preproc'
+end, events)
+
+for i = 1, #events do
+ local ev = events[i]
+ assert(ev.return_type == 'void')
+
+ if ev.since == nil and not ev.noexport then
+ print('Ui event ' .. ev.name .. ' lacks since field.\n')
+ os.exit(1)
+ end
+ ev.since = tonumber(ev.since)
+
+ local args = #ev.parameters > 0 and 'args' or 'noargs'
+ if not ev.remote_only then
+ if not ev.remote_impl and not ev.noexport then
+ remote_output:write('void remote_ui_' .. ev.name)
+ write_signature(remote_output, ev, 'RemoteUI *ui')
+ remote_output:write('\n{\n')
+ write_arglist(remote_output, ev)
+ remote_output:write(' push_call(ui, "' .. ev.name .. '", ' .. args .. ');\n')
+ remote_output:write('}\n\n')
+ end
+ end
+
+ if not (ev.remote_only and ev.remote_impl) then
+ call_output:write('void ui_call_' .. ev.name)
+ write_signature(call_output, ev, '')
+ call_output:write('\n{\n')
+ if ev.remote_only then
+ -- Lua callbacks may emit other events or the same event again. Avoid the latter
+ -- by adding a recursion guard to each generated function that may call a Lua callback.
+ call_output:write(' static bool entered = false;\n')
+ call_output:write(' if (entered) {\n')
+ call_output:write(' return;\n')
+ call_output:write(' }\n')
+ call_output:write(' entered = true;\n')
+ write_arglist(call_output, ev)
+ call_output:write((' ui_call_event("%s", %s, %s)'):format(ev.name, tostring(ev.fast), args))
+ call_output:write(';\n entered = false;\n')
+ elseif ev.compositor_impl then
+ call_output:write(' ui_comp_' .. ev.name)
+ write_signature(call_output, ev, '', true)
+ call_output:write(';\n')
+ call_output:write(' UI_CALL')
+ write_signature(call_output, ev, '!ui->composed, ' .. ev.name .. ', ui', true)
+ call_output:write(';\n')
+ else
+ call_output:write(' UI_CALL')
+ write_signature(call_output, ev, 'true, ' .. ev.name .. ', ui', true)
+ call_output:write(';\n')
+ end
+ call_output:write('}\n\n')
+ end
+
+ if ev.compositor_impl then
+ call_output:write('void ui_composed_call_' .. ev.name)
+ write_signature(call_output, ev, '')
+ call_output:write('\n{\n')
+ call_output:write(' UI_CALL')
+ write_signature(call_output, ev, 'ui->composed, ' .. ev.name .. ', ui', true)
+ call_output:write(';\n')
+ call_output:write('}\n\n')
+ end
+
+ if (not ev.remote_only) and not ev.noexport and not ev.client_impl and not ev.client_ignore then
+ call_ui_event_method(client_output, ev)
+ end
+end
+
+local client_events = {}
+for _, ev in ipairs(events) do
+ if (not ev.noexport) and ((not ev.remote_only) or ev.client_impl) and not ev.client_ignore then
+ client_events[ev.name] = ev
+ end
+end
+
+local hashorder, hashfun = hashy.hashy_hash(
+ 'ui_client_handler',
+ vim.tbl_keys(client_events),
+ function(idx)
+ return 'event_handlers[' .. idx .. '].name'
+ end
+)
+
+client_output:write('static const UIClientHandler event_handlers[] = {\n')
+
+for _, name in ipairs(hashorder) do
+ client_output:write(' { .name = "' .. name .. '", .fn = ui_client_event_' .. name .. '},\n')
+end
+
+client_output:write('\n};\n\n')
+client_output:write(hashfun)
+
+call_output:close()
+remote_output:close()
+client_output:close()
+
+-- don't expose internal attributes like "impl_name" in public metadata
+local exported_attributes = { 'name', 'parameters', 'since', 'deprecated_since' }
+local exported_events = {}
+for _, ev in ipairs(events) do
+ local ev_exported = {}
+ for _, attr in ipairs(exported_attributes) do
+ ev_exported[attr] = ev[attr]
+ end
+ for _, p in ipairs(ev_exported.parameters) do
+ if p[1] == 'HlAttrs' or p[1] == 'Dict' then
+ -- TODO(justinmk): for back-compat, but do clients actually look at this?
+ p[1] = 'Dictionary'
+ end
+ end
+ if not ev.noexport then
+ exported_events[#exported_events + 1] = ev_exported
+ end
+end
+
+metadata_output:write(mpack.encode(exported_events))
+metadata_output:close()
diff --git a/src/gen/gen_char_blob.lua b/src/gen/gen_char_blob.lua
new file mode 100644
index 0000000000..c40e0d6e82
--- /dev/null
+++ b/src/gen/gen_char_blob.lua
@@ -0,0 +1,96 @@
+if arg[1] == '--help' then
+ print('Usage:')
+ print(' ' .. arg[0] .. ' [-c] target source varname [source varname]...')
+ print('')
+ print('Generates C file with big uint8_t blob.')
+ print('Blob will be stored in a static const array named varname.')
+ os.exit()
+end
+
+-- Recognized options:
+-- -c compile Lua bytecode
+local options = {}
+
+while true do
+ local opt = string.match(arg[1], '^-(%w)')
+ if not opt then
+ break
+ end
+
+ options[opt] = true
+ table.remove(arg, 1)
+end
+
+assert(#arg >= 3 and (#arg - 1) % 2 == 0)
+
+local target_file = arg[1] or error('Need a target file')
+local target = io.open(target_file, 'w')
+
+target:write('#include <stdint.h>\n\n')
+
+local index_items = {}
+
+local warn_on_missing_compiler = true
+local modnames = {}
+for argi = 2, #arg, 2 do
+ local source_file = arg[argi]
+ local modname = arg[argi + 1]
+ if modnames[modname] then
+ error(string.format('modname %q is already specified for file %q', modname, modnames[modname]))
+ end
+ modnames[modname] = source_file
+
+ local varname = string.gsub(modname, '%.', '_dot_') .. '_module'
+ target:write(('static const uint8_t %s[] = {\n'):format(varname))
+
+ local output
+ if options.c then
+ local luac = os.getenv('LUAC_PRG')
+ if luac and luac ~= '' then
+ output = io.popen(luac:format(source_file), 'r'):read('*a')
+ elseif warn_on_missing_compiler then
+ print('LUAC_PRG is missing, embedding raw source')
+ warn_on_missing_compiler = false
+ end
+ end
+
+ if not output then
+ local source = io.open(source_file, 'r')
+ or error(string.format("source_file %q doesn't exist", source_file))
+ output = source:read('*a')
+ source:close()
+ end
+
+ local num_bytes = 0
+ local MAX_NUM_BYTES = 15 -- 78 / 5: maximum number of bytes on one line
+ target:write(' ')
+
+ local increase_num_bytes
+ increase_num_bytes = function()
+ num_bytes = num_bytes + 1
+ if num_bytes == MAX_NUM_BYTES then
+ num_bytes = 0
+ target:write('\n ')
+ end
+ end
+
+ for i = 1, string.len(output) do
+ local byte = output:byte(i)
+ target:write(string.format(' %3u,', byte))
+ increase_num_bytes()
+ end
+
+ target:write(' 0};\n')
+ if modname ~= '_' then
+ table.insert(
+ index_items,
+ ' { "' .. modname .. '", ' .. varname .. ', sizeof ' .. varname .. ' },\n\n'
+ )
+ end
+end
+
+target:write('static ModuleDef builtin_modules[] = {\n')
+target:write(table.concat(index_items))
+target:write('};\n')
+
+target:close()
diff --git a/src/gen/gen_declarations.lua b/src/gen/gen_declarations.lua
new file mode 100644
index 0000000000..582ac756b4
--- /dev/null
+++ b/src/gen/gen_declarations.lua
@@ -0,0 +1,186 @@
+local grammar = require('gen.c_grammar').grammar
+
+--- @param fname string
+--- @return string?
+local function read_file(fname)
+ local f = io.open(fname, 'r')
+ if not f then
+ return
+ end
+ local contents = f:read('*a')
+ f:close()
+ return contents
+end
+
+--- @param fname string
+--- @param contents string[]
+local function write_file(fname, contents)
+ local contents_s = table.concat(contents, '\n') .. '\n'
+ local fcontents = read_file(fname)
+ if fcontents == contents_s then
+ return
+ end
+ local f = assert(io.open(fname, 'w'))
+ f:write(contents_s)
+ f:close()
+end
+
+--- @param fname string
+--- @param non_static_fname string
+--- @return string? non_static
+local function add_iwyu_non_static(fname, non_static_fname)
+ if fname:find('.*/src/nvim/.*%.c$') then
+ -- Add an IWYU pragma comment if the corresponding .h file exists.
+ local header_fname = fname:sub(1, -3) .. '.h'
+ local header_f = io.open(header_fname, 'r')
+ if header_f then
+ header_f:close()
+ return (header_fname:gsub('.*/src/nvim/', 'nvim/'))
+ end
+ elseif non_static_fname:find('/include/api/private/dispatch_wrappers%.h%.generated%.h$') then
+ return 'nvim/api/private/dispatch.h'
+ elseif non_static_fname:find('/include/ui_events_call%.h%.generated%.h$') then
+ return 'nvim/ui.h'
+ elseif non_static_fname:find('/include/ui_events_client%.h%.generated%.h$') then
+ return 'nvim/ui_client.h'
+ elseif non_static_fname:find('/include/ui_events_remote%.h%.generated%.h$') then
+ return 'nvim/api/ui.h'
+ end
+end
+
+--- @param d string
+local function process_decl(d)
+ -- Comments are really handled by preprocessor, so the following is not
+ -- needed
+ d = d:gsub('/%*.-%*/', '')
+ d = d:gsub('//.-\n', '\n')
+ d = d:gsub('# .-\n', '')
+ d = d:gsub('\n', ' ')
+ d = d:gsub('%s+', ' ')
+ d = d:gsub(' ?%( ?', '(')
+ d = d:gsub(' ?, ?', ', ')
+ d = d:gsub(' ?(%*+) ?', ' %1')
+ d = d:gsub(' ?(FUNC_ATTR_)', ' %1')
+ d = d:gsub(' $', '')
+ d = d:gsub('^ ', '')
+ return d .. ';'
+end
+
+--- @param fname string
+--- @param text string
+--- @return string[] static
+--- @return string[] non_static
+--- @return boolean any_static
+local function gen_declarations(fname, text)
+ local non_static = {} --- @type string[]
+ local static = {} --- @type string[]
+
+ local neededfile = fname:match('[^/]+$')
+ local curfile = nil
+ local any_static = false
+ for _, node in ipairs(grammar:match(text)) do
+ if node[1] == 'preproc' then
+ curfile = node.content:match('^%a* %d+ "[^"]-/?([^"/]+)"') or curfile
+ elseif node[1] == 'proto' and curfile == neededfile then
+ local node_text = text:sub(node.pos, node.endpos - 1)
+ local declaration = process_decl(node_text)
+
+ if node.static then
+ if not any_static and declaration:find('FUNC_ATTR_') then
+ any_static = true
+ end
+ static[#static + 1] = declaration
+ else
+ non_static[#non_static + 1] = 'DLLEXPORT ' .. declaration
+ end
+ end
+ end
+
+ return static, non_static, any_static
+end
+
+local usage = [[
+Usage:
+
+ gen_declarations.lua definitions.c static.h non-static.h definitions.i
+
+Generates declarations for a C file definitions.c, putting declarations for
+static functions into static.h and declarations for non-static functions into
+non-static.h. File `definitions.i' should contain an already preprocessed
+version of definitions.c and it is the only one which is actually parsed,
+definitions.c is needed only to determine functions from which file out of all
+functions found in definitions.i are needed and to generate an IWYU comment.
+]]
+
+local function main()
+ local fname = arg[1]
+ local static_fname = arg[2]
+ local non_static_fname = arg[3]
+ local preproc_fname = arg[4]
+ local static_basename = arg[5]
+
+ if fname == '--help' or #arg < 5 then
+ print(usage)
+ os.exit()
+ end
+
+ local text = assert(read_file(preproc_fname))
+
+ local static_decls, non_static_decls, any_static = gen_declarations(fname, text)
+
+ local static = {} --- @type string[]
+ if fname:find('.*/src/nvim/.*%.h$') then
+ static[#static + 1] = ('// IWYU pragma: private, include "%s"'):format(
+ fname:gsub('.*/src/nvim/', 'nvim/')
+ )
+ end
+ vim.list_extend(static, {
+ '#define DEFINE_FUNC_ATTRIBUTES',
+ '#include "nvim/func_attr.h"',
+ '#undef DEFINE_FUNC_ATTRIBUTES',
+ })
+ vim.list_extend(static, static_decls)
+ vim.list_extend(static, {
+ '#define DEFINE_EMPTY_ATTRIBUTES',
+ '#include "nvim/func_attr.h" // IWYU pragma: export',
+ '',
+ })
+
+ write_file(static_fname, static)
+
+ if any_static then
+ local orig_text = assert(read_file(fname))
+ local pat = '\n#%s?include%s+"' .. static_basename .. '"\n'
+ local pat_comment = '\n#%s?include%s+"' .. static_basename .. '"%s*//'
+ if not orig_text:find(pat) and not orig_text:find(pat_comment) then
+ error(('fail: missing include for %s in %s'):format(static_basename, fname))
+ end
+ end
+
+ if non_static_fname ~= 'SKIP' then
+ local non_static = {} --- @type string[]
+ local iwyu_non_static = add_iwyu_non_static(fname, non_static_fname)
+ if iwyu_non_static then
+ non_static[#non_static + 1] = ('// IWYU pragma: private, include "%s"'):format(
+ iwyu_non_static
+ )
+ end
+ vim.list_extend(non_static, {
+ '#define DEFINE_FUNC_ATTRIBUTES',
+ '#include "nvim/func_attr.h"',
+ '#undef DEFINE_FUNC_ATTRIBUTES',
+ '#ifndef DLLEXPORT',
+ '# ifdef MSWIN',
+ '# define DLLEXPORT __declspec(dllexport)',
+ '# else',
+ '# define DLLEXPORT',
+ '# endif',
+ '#endif',
+ })
+ vim.list_extend(non_static, non_static_decls)
+ non_static[#non_static + 1] = '#include "nvim/func_attr.h"'
+ write_file(non_static_fname, non_static)
+ end
+end
+
+return main()
diff --git a/src/gen/gen_eval.lua b/src/gen/gen_eval.lua
new file mode 100644
index 0000000000..9d2f2f7523
--- /dev/null
+++ b/src/gen/gen_eval.lua
@@ -0,0 +1,112 @@
+local mpack = vim.mpack
+
+local autodir = arg[1]
+local metadata_file = arg[2]
+local funcs_file = arg[3]
+
+local funcsfname = autodir .. '/funcs.generated.h'
+
+--Will generate funcs.generated.h with definition of functions static const array.
+
+local hashy = require 'gen.hashy'
+
+local hashpipe = assert(io.open(funcsfname, 'wb'))
+
+hashpipe:write([[
+#include "nvim/arglist.h"
+#include "nvim/cmdexpand.h"
+#include "nvim/cmdhist.h"
+#include "nvim/digraph.h"
+#include "nvim/eval.h"
+#include "nvim/eval/buffer.h"
+#include "nvim/eval/deprecated.h"
+#include "nvim/eval/fs.h"
+#include "nvim/eval/funcs.h"
+#include "nvim/eval/typval.h"
+#include "nvim/eval/vars.h"
+#include "nvim/eval/window.h"
+#include "nvim/ex_docmd.h"
+#include "nvim/ex_getln.h"
+#include "nvim/fold.h"
+#include "nvim/getchar.h"
+#include "nvim/insexpand.h"
+#include "nvim/mapping.h"
+#include "nvim/match.h"
+#include "nvim/mbyte.h"
+#include "nvim/menu.h"
+#include "nvim/mouse.h"
+#include "nvim/move.h"
+#include "nvim/quickfix.h"
+#include "nvim/runtime.h"
+#include "nvim/search.h"
+#include "nvim/state.h"
+#include "nvim/strings.h"
+#include "nvim/sign.h"
+#include "nvim/testing.h"
+#include "nvim/undo.h"
+
+]])
+
+local funcs = require('nvim.eval').funcs
+for _, func in pairs(funcs) do
+ if func.float_func then
+ func.func = 'float_op_wrapper'
+ func.data = '{ .float_func = &' .. func.float_func .. ' }'
+ end
+end
+
+local metadata = mpack.decode(io.open(metadata_file, 'rb'):read('*all'))
+for _, fun in ipairs(metadata) do
+ if fun.eval then
+ funcs[fun.name] = {
+ args = #fun.parameters,
+ func = 'api_wrapper',
+ data = '{ .api_handler = &method_handlers[' .. fun.handler_id .. '] }',
+ }
+ end
+end
+
+local func_names = vim.tbl_filter(function(name)
+ return name:match('__%d*$') == nil
+end, vim.tbl_keys(funcs))
+
+table.sort(func_names)
+
+local funcsdata = assert(io.open(funcs_file, 'w'))
+funcsdata:write(mpack.encode(func_names))
+funcsdata:close()
+
+local neworder, hashfun = hashy.hashy_hash('find_internal_func', func_names, function(idx)
+ return 'functions[' .. idx .. '].name'
+end)
+
+hashpipe:write('static const EvalFuncDef functions[] = {\n')
+
+for _, name in ipairs(neworder) do
+ local def = funcs[name]
+ local args = def.args or 0
+ if type(args) == 'number' then
+ args = { args, args }
+ elseif #args == 1 then
+ args[2] = 'MAX_FUNC_ARGS'
+ end
+ local base = def.base or 'BASE_NONE'
+ local func = def.func or ('f_' .. name)
+ local data = def.data or '{ .null = NULL }'
+ local fast = def.fast and 'true' or 'false'
+ hashpipe:write(
+ (' { "%s", %s, %s, %s, %s, &%s, %s },\n'):format(
+ name,
+ args[1],
+ args[2],
+ base,
+ fast,
+ func,
+ data
+ )
+ )
+end
+hashpipe:write(' { NULL, 0, 0, BASE_NONE, false, NULL, { .null = NULL } },\n')
+hashpipe:write('};\n\n')
+hashpipe:write(hashfun)
+hashpipe:close()
diff --git a/src/gen/gen_eval_files.lua b/src/gen/gen_eval_files.lua
new file mode 100755
index 0000000000..74e45507e5
--- /dev/null
+++ b/src/gen/gen_eval_files.lua
@@ -0,0 +1,1090 @@
+#!/usr/bin/env -S nvim -l
+
+-- Generator for various vimdoc and Lua type files
+
+local util = require('gen.util')
+local fmt = string.format
+
+local DEP_API_METADATA = 'build/funcs_metadata.mpack'
+local TEXT_WIDTH = 78
+
+--- @class vim.api.metadata
+--- @field name string
+--- @field parameters [string,string][]
+--- @field return_type string
+--- @field deprecated_since integer
+--- @field eval boolean
+--- @field fast boolean
+--- @field handler_id integer
+--- @field impl_name string
+--- @field lua boolean
+--- @field method boolean
+--- @field remote boolean
+--- @field since integer
+
+local LUA_API_RETURN_OVERRIDES = {
+ nvim_buf_get_command = 'table<string,vim.api.keyset.command_info>',
+ nvim_buf_get_extmark_by_id = 'vim.api.keyset.get_extmark_item_by_id',
+ nvim_buf_get_extmarks = 'vim.api.keyset.get_extmark_item[]',
+ nvim_buf_get_keymap = 'vim.api.keyset.get_keymap[]',
+ nvim_get_autocmds = 'vim.api.keyset.get_autocmds.ret[]',
+ nvim_get_color_map = 'table<string,integer>',
+ nvim_get_command = 'table<string,vim.api.keyset.command_info>',
+ nvim_get_keymap = 'vim.api.keyset.get_keymap[]',
+ nvim_get_mark = 'vim.api.keyset.get_mark',
+
+ -- Can also return table<string,vim.api.keyset.get_hl_info>, however we need to
+ -- pick one to get some benefit.
+ -- REVISIT lewrus01 (26/01/24): we can maybe add
+ -- @overload fun(ns: integer, {}): table<string,vim.api.keyset.get_hl_info>
+ nvim_get_hl = 'vim.api.keyset.get_hl_info',
+
+ nvim_get_mode = 'vim.api.keyset.get_mode',
+ nvim_get_namespaces = 'table<string,integer>',
+ nvim_get_option_info = 'vim.api.keyset.get_option_info',
+ nvim_get_option_info2 = 'vim.api.keyset.get_option_info',
+ nvim_parse_cmd = 'vim.api.keyset.parse_cmd',
+ nvim_win_get_config = 'vim.api.keyset.win_config',
+}
+
+local LUA_API_KEYSET_OVERRIDES = {
+ create_autocmd = {
+ callback = 'string|(fun(args: vim.api.keyset.create_autocmd.callback_args): boolean?)',
+ },
+}
+
+local LUA_API_PARAM_OVERRIDES = {
+ nvim_create_user_command = {
+ command = 'string|fun(args: vim.api.keyset.create_user_command.command_args)',
+ },
+}
+
+local LUA_META_HEADER = {
+ '--- @meta _',
+ '-- THIS FILE IS GENERATED',
+ '-- DO NOT EDIT',
+ "error('Cannot require a meta file')",
+}
+
+local LUA_API_META_HEADER = {
+ '--- @meta _',
+ '-- THIS FILE IS GENERATED',
+ '-- DO NOT EDIT',
+ "error('Cannot require a meta file')",
+ '',
+ '--- This file embeds vimdoc as the function descriptions',
+ '--- so ignore any doc related errors.',
+ '--- @diagnostic disable: undefined-doc-name,luadoc-miss-symbol',
+ '',
+ 'vim.api = {}',
+}
+
+local LUA_OPTION_META_HEADER = {
+ '--- @meta _',
+ '-- THIS FILE IS GENERATED',
+ '-- DO NOT EDIT',
+ "error('Cannot require a meta file')",
+ '',
+ '---@class vim.bo',
+ '---@field [integer] vim.bo',
+ 'vim.bo = vim.bo',
+ '',
+ '---@class vim.wo',
+ '---@field [integer] vim.wo',
+ 'vim.wo = vim.wo',
+}
+
+local LUA_VVAR_META_HEADER = {
+ '--- @meta _',
+ '-- THIS FILE IS GENERATED',
+ '-- DO NOT EDIT',
+ "error('Cannot require a meta file')",
+ '',
+ '--- @class vim.v',
+ 'vim.v = ...',
+}
+
+local LUA_KEYWORDS = {
+ ['and'] = true,
+ ['end'] = true,
+ ['function'] = true,
+ ['or'] = true,
+ ['if'] = true,
+ ['while'] = true,
+ ['repeat'] = true,
+ ['true'] = true,
+ ['false'] = true,
+}
+
+local OPTION_TYPES = {
+ boolean = 'boolean',
+ number = 'integer',
+ string = 'string',
+}
+
+local API_TYPES = {
+ Window = 'integer',
+ Tabpage = 'integer',
+ Buffer = 'integer',
+ Boolean = 'boolean',
+ Object = 'any',
+ Integer = 'integer',
+ String = 'string',
+ Array = 'any[]',
+ LuaRef = 'function',
+ Dict = 'table<string,any>',
+ Float = 'number',
+ HLGroupID = 'integer|string',
+ void = '',
+}
+
+--- @param s string
+--- @return string
+local function luaescape(s)
+ if LUA_KEYWORDS[s] then
+ return s .. '_'
+ end
+ return s
+end
+
+--- @param x string
+--- @param sep? string
+--- @return string[]
+local function split(x, sep)
+ return vim.split(x, sep or '\n', { plain = true })
+end
+
+--- Convert an API type to Lua
+--- @param t string
+--- @return string
+local function api_type(t)
+ if vim.startswith(t, '*') then
+ return api_type(t:sub(2)) .. '?'
+ end
+
+ local as0 = t:match('^ArrayOf%((.*)%)')
+ if as0 then
+ local as = split(as0, ', ')
+ return api_type(as[1]) .. '[]'
+ end
+
+ local d = t:match('^Dict%((.*)%)')
+ if d then
+ return 'vim.api.keyset.' .. d
+ end
+
+ local d0 = t:match('^DictOf%((.*)%)')
+ if d0 then
+ return 'table<string,' .. api_type(d0) .. '>'
+ end
+
+ local u = t:match('^Union%((.*)%)')
+ if u then
+ local us = vim.split(u, ',%s*')
+ return table.concat(vim.tbl_map(api_type, us), '|')
+ end
+
+ local l = t:match('^LuaRefOf%((.*)%)')
+ if l then
+ --- @type string
+ l = l:gsub('%s+', ' ')
+ --- @type string?, string?
+ local as, r = l:match('%((.*)%),%s*(.*)')
+ if not as then
+ --- @type string
+ as = assert(l:match('%((.*)%)'))
+ end
+
+ local as1 = {} --- @type string[]
+ for a in vim.gsplit(as, ',%s') do
+ local a1 = vim.split(a, '%s+', { trimempty = true })
+ local nm = a1[2]:gsub('%*(.*)$', '%1?')
+ as1[#as1 + 1] = nm .. ': ' .. api_type(a1[1])
+ end
+
+ return ('fun(%s)%s'):format(table.concat(as1, ', '), r and ': ' .. api_type(r) or '')
+ end
+
+ return API_TYPES[t] or t
+end
+
+--- @param f string
+--- @param params [string,string][]|true
+--- @return string
+local function render_fun_sig(f, params)
+ local param_str --- @type string
+ if params == true then
+ param_str = '...'
+ else
+ param_str = table.concat(
+ vim.tbl_map(
+ --- @param v [string,string]
+ --- @return string
+ function(v)
+ return luaescape(v[1])
+ end,
+ params
+ ),
+ ', '
+ )
+ end
+
+ if LUA_KEYWORDS[f] then
+ return fmt("vim.fn['%s'] = function(%s) end", f, param_str)
+ else
+ return fmt('function vim.fn.%s(%s) end', f, param_str)
+ end
+end
+
+--- Uniquify names
+--- @param params [string,string,string][]
+--- @return [string,string,string][]
+local function process_params(params)
+ local seen = {} --- @type table<string,true>
+ local sfx = 1
+
+ for _, p in ipairs(params) do
+ if seen[p[1]] then
+ p[1] = p[1] .. sfx
+ sfx = sfx + 1
+ else
+ seen[p[1]] = true
+ end
+ end
+
+ return params
+end
+
+--- @return table<string, vim.EvalFn>
+local function get_api_meta()
+ local ret = {} --- @type table<string, vim.EvalFn>
+
+ local cdoc_parser = require('gen.cdoc_parser')
+
+ local f = 'src/nvim/api'
+
+ local function include(fun)
+ if not vim.startswith(fun.name, 'nvim_') then
+ return false
+ end
+ if vim.tbl_contains(fun.attrs or {}, 'lua_only') then
+ return true
+ end
+ if vim.tbl_contains(fun.attrs or {}, 'remote_only') then
+ return false
+ end
+ return true
+ end
+
+ --- @type table<string,nvim.cdoc.parser.fun>
+ local functions = {}
+ for path, ty in vim.fs.dir(f) do
+ if ty == 'file' then
+ local filename = vim.fs.joinpath(f, path)
+ local _, funs = cdoc_parser.parse(filename)
+ for _, fn in ipairs(funs) do
+ if include(fn) then
+ functions[fn.name] = fn
+ end
+ end
+ end
+ end
+
+ for _, fun in pairs(functions) do
+ local deprecated = fun.deprecated_since ~= nil
+
+ local notes = {} --- @type string[]
+ for _, note in ipairs(fun.notes or {}) do
+ notes[#notes + 1] = note.desc
+ end
+
+ local sees = {} --- @type string[]
+ for _, see in ipairs(fun.see or {}) do
+ sees[#sees + 1] = see.desc
+ end
+
+ local pty_overrides = LUA_API_PARAM_OVERRIDES[fun.name] or {}
+
+ local params = {} --- @type [string,string][]
+ for _, p in ipairs(fun.params) do
+ params[#params + 1] = {
+ p.name,
+ api_type(pty_overrides[p.name] or p.type),
+ not deprecated and p.desc or nil,
+ }
+ end
+
+ local r = {
+ signature = 'NA',
+ name = fun.name,
+ params = params,
+ notes = notes,
+ see = sees,
+ returns = api_type(fun.returns[1].type),
+ deprecated = deprecated,
+ }
+
+ if not deprecated then
+ r.desc = fun.desc
+ r.returns_desc = fun.returns[1].desc
+ end
+
+ ret[fun.name] = r
+ end
+ return ret
+end
+
+--- Convert vimdoc references to markdown literals
+--- Convert vimdoc codeblocks to markdown codeblocks
+---
+--- Ensure code blocks have one empty line before the start fence and after the closing fence.
+---
+--- @param x string
+--- @param special string?
+--- | 'see-api-meta' Normalize `@see` for API meta docstrings.
+--- @return string
+local function norm_text(x, special)
+ if special == 'see-api-meta' then
+ -- Try to guess a symbol that actually works in @see.
+ -- "nvim_xx()" => "vim.api.nvim_xx"
+ x = x:gsub([=[%|?(nvim_[^.()| ]+)%(?%)?%|?]=], 'vim.api.%1')
+ -- TODO: Remove backticks when LuaLS resolves: https://github.com/LuaLS/lua-language-server/issues/2889
+ -- "|foo|" => "`:help foo`"
+ x = x:gsub([=[|([^ ]+)|]=], '`:help %1`')
+ end
+
+ return (
+ x:gsub('|([^ ]+)|', '`%1`')
+ :gsub('\n*>lua', '\n\n```lua')
+ :gsub('\n*>vim', '\n\n```vim')
+ :gsub('\n+<$', '\n```')
+ :gsub('\n+<\n+', '\n```\n\n')
+ :gsub('%s+>\n+', '\n```\n')
+ :gsub('\n+<%s+\n?', '\n```\n')
+ )
+end
+
+--- Generates LuaLS docstring for an API function.
+--- @param _f string
+--- @param fun vim.EvalFn
+--- @param write fun(line: string)
+local function render_api_meta(_f, fun, write)
+ write('')
+
+ if vim.startswith(fun.name, 'nvim__') then
+ write('--- @private')
+ end
+
+ if fun.deprecated then
+ write('--- @deprecated')
+ end
+
+ local desc = fun.desc
+ if desc then
+ write(util.prefix_lines('--- ', norm_text(desc)))
+ end
+
+ -- LuaLS doesn't support @note. Render @note items as a markdown list.
+ if fun.notes and #fun.notes > 0 then
+ write('--- Note:')
+ write(util.prefix_lines('--- ', table.concat(fun.notes, '\n')))
+ write('---')
+ end
+
+ for _, see in ipairs(fun.see or {}) do
+ write(util.prefix_lines('--- @see ', norm_text(see, 'see-api-meta')))
+ end
+
+ local param_names = {} --- @type string[]
+ local params = process_params(fun.params)
+ for _, p in ipairs(params) do
+ local pname, ptype, pdesc = luaescape(p[1]), p[2], p[3]
+ param_names[#param_names + 1] = pname
+ if pdesc then
+ local s = '--- @param ' .. pname .. ' ' .. ptype .. ' '
+ local pdesc_a = split(vim.trim(norm_text(pdesc)))
+ write(s .. pdesc_a[1])
+ for i = 2, #pdesc_a do
+ if not pdesc_a[i] then
+ break
+ end
+ write('--- ' .. pdesc_a[i])
+ end
+ else
+ write('--- @param ' .. pname .. ' ' .. ptype)
+ end
+ end
+
+ if fun.returns ~= '' then
+ local ret_desc = fun.returns_desc and ' # ' .. fun.returns_desc or ''
+ local ret = LUA_API_RETURN_OVERRIDES[fun.name] or fun.returns
+ write(util.prefix_lines('--- ', '@return ' .. ret .. ret_desc))
+ end
+ local param_str = table.concat(param_names, ', ')
+
+ write(fmt('function vim.api.%s(%s) end', fun.name, param_str))
+end
+
+--- @return table<string, vim.EvalFn>
+local function get_api_keysets_meta()
+ local mpack_f = assert(io.open(DEP_API_METADATA, 'rb'))
+ local metadata = assert(vim.mpack.decode(mpack_f:read('*all')))
+
+ local ret = {} --- @type table<string, vim.EvalFn>
+
+ --- @type {name: string, keys: string[], types: table<string,string>}[]
+ local keysets = metadata.keysets
+
+ for _, k in ipairs(keysets) do
+ local pty_overrides = LUA_API_KEYSET_OVERRIDES[k.name] or {}
+ local params = {}
+ for _, key in ipairs(k.keys) do
+ local pty = pty_overrides[key] or k.types[key] or 'any'
+ table.insert(params, { key .. '?', api_type(pty) })
+ end
+ ret[k.name] = {
+ signature = 'NA',
+ name = k.name,
+ params = params,
+ }
+ end
+
+ return ret
+end
+
+--- Generates LuaLS docstring for an API keyset.
+--- @param _f string
+--- @param fun vim.EvalFn
+--- @param write fun(line: string)
+local function render_api_keyset_meta(_f, fun, write)
+ if string.sub(fun.name, 1, 1) == '_' then
+ return -- not exported
+ end
+ write('')
+ write('--- @class vim.api.keyset.' .. fun.name)
+ for _, p in ipairs(fun.params) do
+ write('--- @field ' .. p[1] .. ' ' .. p[2])
+ end
+end
+
+--- @return table<string, vim.EvalFn>
+local function get_eval_meta()
+ return require('nvim.eval').funcs
+end
+
+--- Generates LuaLS docstring for a Vimscript "eval" function.
+--- @param f string
+--- @param fun vim.EvalFn
+--- @param write fun(line: string)
+local function render_eval_meta(f, fun, write)
+ if fun.lua == false then
+ return
+ end
+
+ local funname = fun.name or f
+ local params = process_params(fun.params)
+
+ write('')
+ if fun.deprecated then
+ write('--- @deprecated')
+ end
+
+ local desc = fun.desc
+
+ if desc then
+ --- @type string
+ desc = desc:gsub('\n%s*\n%s*$', '\n')
+ for _, l in ipairs(split(desc)) do
+ l = l:gsub('^ ', ''):gsub('\t', ' '):gsub('@', '\\@')
+ write('--- ' .. l)
+ end
+ end
+
+ for _, text in ipairs(vim.fn.reverse(fun.generics or {})) do
+ write(fmt('--- @generic %s', text))
+ end
+
+ local req_args = type(fun.args) == 'table' and fun.args[1] or fun.args or 0
+
+ for i, param in ipairs(params) do
+ local pname, ptype = luaescape(param[1]), param[2]
+ local optional = (pname ~= '...' and i > req_args) and '?' or ''
+ write(fmt('--- @param %s%s %s', pname, optional, ptype))
+ end
+
+ if fun.returns ~= false then
+ local ret_desc = fun.returns_desc and ' # ' .. fun.returns_desc or ''
+ write('--- @return ' .. (fun.returns or 'any') .. ret_desc)
+ end
+
+ write(render_fun_sig(funname, params))
+end
+
+--- Generates vimdoc heading for a Vimscript "eval" function signature.
+--- @param name string
+--- @param name_tag boolean
+--- @param fun vim.EvalFn
+--- @param write fun(line: string)
+local function render_sig_and_tag(name, name_tag, fun, write)
+ if not fun.signature then
+ return
+ end
+
+ local tags = name_tag and { '*' .. name .. '()*' } or {}
+
+ if fun.tags then
+ for _, t in ipairs(fun.tags) do
+ tags[#tags + 1] = '*' .. t .. '*'
+ end
+ end
+
+ if #tags == 0 then
+ write(fun.signature)
+ return
+ end
+
+ local tag = table.concat(tags, ' ')
+ local siglen = #fun.signature
+ local conceal_offset = 2 * (#tags - 1)
+ local tag_pad_len = math.max(1, 80 - #tag + conceal_offset)
+
+ if siglen + #tag > 80 then
+ write(string.rep(' ', tag_pad_len) .. tag)
+ write(fun.signature)
+ else
+ write(fmt('%s%s%s', fun.signature, string.rep(' ', tag_pad_len - siglen), tag))
+ end
+end
+
+--- Generates vimdoc for a Vimscript "eval" function.
+--- @param f string
+--- @param fun vim.EvalFn
+--- @param write fun(line: string)
+local function render_eval_doc(f, fun, write)
+ if fun.deprecated or not fun.signature then
+ return
+ end
+
+ render_sig_and_tag(fun.name or f, not f:find('__%d+$'), fun, write)
+
+ if not fun.desc then
+ return
+ end
+
+ local params = process_params(fun.params)
+ local req_args = type(fun.args) == 'table' and fun.args[1] or fun.args or 0
+
+ local desc_l = split(vim.trim(fun.desc))
+ for _, l in ipairs(desc_l) do
+ l = l:gsub('^ ', '')
+ if vim.startswith(l, '<') and not l:match('^<[^ \t]+>') then
+ write('<\t\t' .. l:sub(2))
+ elseif l:match('^>[a-z0-9]*$') then
+ write(l)
+ else
+ write('\t\t' .. l)
+ end
+ end
+
+ if #desc_l > 0 and not desc_l[#desc_l]:match('^<?$') then
+ write('')
+ end
+
+ if #params > 0 then
+ write(util.md_to_vimdoc('Parameters: ~', 16, 16, TEXT_WIDTH))
+ for i, param in ipairs(params) do
+ local pname, ptype = param[1], param[2]
+ local optional = (pname ~= '...' and i > req_args) and '?' or ''
+ local s = fmt('- %-14s (`%s%s`)', fmt('{%s}', pname), ptype, optional)
+ write(util.md_to_vimdoc(s, 16, 18, TEXT_WIDTH))
+ end
+ write('')
+ end
+
+ if fun.returns ~= false then
+ write(util.md_to_vimdoc('Return: ~', 16, 16, TEXT_WIDTH))
+ local ret = ('(`%s`)'):format((fun.returns or 'any'))
+ ret = ret .. (fun.returns_desc and ' ' .. fun.returns_desc or '')
+ ret = util.md_to_vimdoc(ret, 18, 18, TEXT_WIDTH)
+ write(ret)
+ write('')
+ end
+end
+
+--- @param d vim.option_defaults
+--- @param vimdoc? boolean
+--- @return string
+local function render_option_default(d, vimdoc)
+ local dt --- @type integer|boolean|string|fun(): string
+ if d.if_false ~= nil then
+ dt = d.if_false
+ else
+ dt = d.if_true
+ end
+
+ if vimdoc then
+ if d.doc then
+ return d.doc
+ end
+ if type(dt) == 'boolean' then
+ return dt and 'on' or 'off'
+ end
+ end
+
+ if dt == '' or dt == nil or type(dt) == 'function' then
+ dt = d.meta
+ end
+
+ local v --- @type string
+ if not vimdoc then
+ v = vim.inspect(dt) --[[@as string]]
+ else
+ v = type(dt) == 'string' and '"' .. dt .. '"' or tostring(dt)
+ end
+
+ --- @type table<string, string|false>
+ local envvars = {
+ TMPDIR = false,
+ VIMRUNTIME = false,
+ XDG_CONFIG_HOME = vim.env.HOME .. '/.local/config',
+ XDG_DATA_HOME = vim.env.HOME .. '/.local/share',
+ XDG_STATE_HOME = vim.env.HOME .. '/.local/state',
+ }
+
+ for name, default in pairs(envvars) do
+ local value = vim.env[name] or default
+ if value then
+ v = v:gsub(vim.pesc(value), '$' .. name)
+ end
+ end
+
+ return v
+end
+
+--- @param _f string
+--- @param opt vim.option_meta
+--- @param write fun(line: string)
+local function render_option_meta(_f, opt, write)
+ write('')
+ for _, l in ipairs(split(norm_text(opt.desc))) do
+ write('--- ' .. l)
+ end
+
+ if opt.type == 'string' and not opt.list and opt.values then
+ local values = {} --- @type string[]
+ for _, e in ipairs(opt.values) do
+ values[#values + 1] = fmt("'%s'", e)
+ end
+ write('--- @type ' .. table.concat(values, '|'))
+ else
+ write('--- @type ' .. OPTION_TYPES[opt.type])
+ end
+
+ write('vim.o.' .. opt.full_name .. ' = ' .. render_option_default(opt.defaults))
+ if opt.abbreviation then
+ write('vim.o.' .. opt.abbreviation .. ' = vim.o.' .. opt.full_name)
+ end
+
+ for _, s in pairs {
+ { 'wo', 'win' },
+ { 'bo', 'buf' },
+ { 'go', 'global' },
+ } do
+ local id, scope = s[1], s[2]
+ if vim.list_contains(opt.scope, scope) or (id == 'go' and #opt.scope > 1) then
+ local pfx = 'vim.' .. id .. '.'
+ write(pfx .. opt.full_name .. ' = vim.o.' .. opt.full_name)
+ if opt.abbreviation then
+ write(pfx .. opt.abbreviation .. ' = ' .. pfx .. opt.full_name)
+ end
+ end
+ end
+end
+
+--- @param _f string
+--- @param opt vim.option_meta
+--- @param write fun(line: string)
+local function render_vvar_meta(_f, opt, write)
+ write('')
+
+ local desc = split(norm_text(opt.desc))
+ while desc[#desc]:match('^%s*$') do
+ desc[#desc] = nil
+ end
+
+ for _, l in ipairs(desc) do
+ write('--- ' .. l)
+ end
+
+ write('--- @type ' .. (opt.type or 'any'))
+
+ if LUA_KEYWORDS[opt.full_name] then
+ write("vim.v['" .. opt.full_name .. "'] = ...")
+ else
+ write('vim.v.' .. opt.full_name .. ' = ...')
+ end
+end
+
+--- @param s string[]
+--- @return string
+local function scope_to_doc(s)
+ local m = {
+ global = 'global',
+ buf = 'local to buffer',
+ win = 'local to window',
+ tab = 'local to tab page',
+ }
+
+ if #s == 1 then
+ return m[s[1]]
+ end
+ assert(s[1] == 'global')
+ return 'global or ' .. m[s[2]] .. (s[2] ~= 'tab' and ' |global-local|' or '')
+end
+
+-- @param o vim.option_meta
+-- @return string
+local function scope_more_doc(o)
+ if
+ vim.list_contains({
+ 'bufhidden',
+ 'buftype',
+ 'filetype',
+ 'modified',
+ 'previewwindow',
+ 'readonly',
+ 'scroll',
+ 'syntax',
+ 'winfixheight',
+ 'winfixwidth',
+ }, o.full_name)
+ then
+ return ' |local-noglobal|'
+ end
+
+ return ''
+end
+
+--- @param x string
+--- @return string
+local function dedent(x)
+ local xs = split(x)
+ local leading_ws = xs[1]:match('^%s*') --[[@as string]]
+ local leading_ws_pat = '^' .. leading_ws
+
+ for i in ipairs(xs) do
+ local strip_pat = xs[i]:match(leading_ws_pat) and leading_ws_pat or '^%s*'
+ xs[i] = xs[i]:gsub(strip_pat, '')
+ end
+
+ return table.concat(xs, '\n')
+end
+
+--- @return table<string,vim.option_meta>
+local function get_option_meta()
+ local opts = require('nvim.options').options
+ local optinfo = vim.api.nvim_get_all_options_info()
+ local ret = {} --- @type table<string,vim.option_meta>
+ for _, o in ipairs(opts) do
+ local is_window_option = #o.scope == 1 and o.scope[1] == 'win'
+ local is_option_hidden = o.immutable and not o.varname and not is_window_option
+ if not is_option_hidden and o.desc then
+ if o.full_name == 'cmdheight' then
+ table.insert(o.scope, 'tab')
+ end
+ local r = vim.deepcopy(o) --[[@as vim.option_meta]]
+ r.desc = o.desc:gsub('^ ', ''):gsub('\n ', '\n')
+ r.defaults = r.defaults or {}
+ if r.defaults.meta == nil then
+ r.defaults.meta = optinfo[o.full_name].default
+ end
+ ret[o.full_name] = r
+ end
+ end
+ return ret
+end
+
+--- @return table<string,vim.option_meta>
+local function get_vvar_meta()
+ local info = require('nvim.vvars').vars
+ local ret = {} --- @type table<string,vim.option_meta>
+ for name, o in pairs(info) do
+ o.desc = dedent(o.desc)
+ o.full_name = name
+ ret[name] = o
+ end
+ return ret
+end
+
+--- @param opt vim.option_meta
+--- @return string[]
+local function build_option_tags(opt)
+ --- @type string[]
+ local tags = { opt.full_name }
+
+ tags[#tags + 1] = opt.abbreviation
+ if opt.type == 'boolean' then
+ for i = 1, #tags do
+ tags[#tags + 1] = 'no' .. tags[i]
+ end
+ end
+
+ for i, t in ipairs(tags) do
+ tags[i] = "'" .. t .. "'"
+ end
+
+ for _, t in ipairs(opt.tags or {}) do
+ tags[#tags + 1] = t
+ end
+
+ for i, t in ipairs(tags) do
+ tags[i] = '*' .. t .. '*'
+ end
+
+ return tags
+end
+
+--- @param _f string
+--- @param opt vim.option_meta
+--- @param write fun(line: string)
+local function render_option_doc(_f, opt, write)
+ local tags = build_option_tags(opt)
+ local tag_str = table.concat(tags, ' ')
+ local conceal_offset = 2 * (#tags - 1)
+ local tag_pad = string.rep('\t', math.ceil((64 - #tag_str + conceal_offset) / 8))
+ -- local pad = string.rep(' ', 80 - #tag_str + conceal_offset)
+ write(tag_pad .. tag_str)
+
+ local name_str --- @type string
+ if opt.abbreviation then
+ name_str = fmt("'%s' '%s'", opt.full_name, opt.abbreviation)
+ else
+ name_str = fmt("'%s'", opt.full_name)
+ end
+
+ local otype = opt.type == 'boolean' and 'boolean' or opt.type
+ if opt.defaults.doc or opt.defaults.if_true ~= nil or opt.defaults.meta ~= nil then
+ local v = render_option_default(opt.defaults, true)
+ local pad = string.rep('\t', math.max(1, math.ceil((24 - #name_str) / 8)))
+ if opt.defaults.doc then
+ local deflen = #fmt('%s%s%s (', name_str, pad, otype)
+ --- @type string
+ v = v:gsub('\n', '\n' .. string.rep(' ', deflen - 2))
+ end
+ write(fmt('%s%s%s\t(default %s)', name_str, pad, otype, v))
+ else
+ write(fmt('%s\t%s', name_str, otype))
+ end
+
+ write('\t\t\t' .. scope_to_doc(opt.scope) .. scope_more_doc(opt))
+ for _, l in ipairs(split(opt.desc)) do
+ if l == '<' or l:match('^<%s') then
+ write(l)
+ else
+ write('\t' .. l:gsub('\\<', '<'))
+ end
+ end
+end
+
+--- @param _f string
+--- @param vvar vim.option_meta
+--- @param write fun(line: string)
+local function render_vvar_doc(_f, vvar, write)
+ local name = vvar.full_name
+
+ local tags = { 'v:' .. name, name .. '-variable' }
+ if vvar.tags then
+ vim.list_extend(tags, vvar.tags)
+ end
+
+ for i, t in ipairs(tags) do
+ tags[i] = '*' .. t .. '*'
+ end
+
+ local tag_str = table.concat(tags, ' ')
+ local conceal_offset = 2 * (#tags - 1)
+
+ local tag_pad = string.rep('\t', math.ceil((64 - #tag_str + conceal_offset) / 8))
+ write(tag_pad .. tag_str)
+
+ local desc = split(vvar.desc)
+
+ if (#desc == 1 or #desc == 2 and desc[2]:match('^%s*$')) and #name < 10 then
+ -- single line
+ write('v:' .. name .. '\t' .. desc[1]:gsub('^%s*', ''))
+ write('')
+ else
+ write('v:' .. name)
+ for _, l in ipairs(desc) do
+ if l == '<' or l:match('^<%s') then
+ write(l)
+ else
+ write('\t\t' .. l:gsub('\\<', '<'))
+ end
+ end
+ end
+end
+
+--- @class nvim.gen_eval_files.elem
+--- @field path string
+--- @field from? string Skip lines in path until this pattern is reached.
+--- @field funcs fun(): table<string, table>
+--- @field render fun(f:string,obj:table,write:fun(line:string))
+--- @field header? string[]
+--- @field footer? string[]
+
+--- @type nvim.gen_eval_files.elem[]
+local CONFIG = {
+ {
+ path = 'runtime/lua/vim/_meta/vimfn.lua',
+ header = LUA_META_HEADER,
+ funcs = get_eval_meta,
+ render = render_eval_meta,
+ },
+ {
+ path = 'runtime/lua/vim/_meta/api.lua',
+ header = LUA_API_META_HEADER,
+ funcs = get_api_meta,
+ render = render_api_meta,
+ },
+ {
+ path = 'runtime/lua/vim/_meta/api_keysets.lua',
+ header = LUA_META_HEADER,
+ funcs = get_api_keysets_meta,
+ render = render_api_keyset_meta,
+ },
+ {
+ path = 'runtime/doc/builtin.txt',
+ funcs = get_eval_meta,
+ render = render_eval_doc,
+ header = {
+ '*builtin.txt* Nvim',
+ '',
+ '',
+ '\t\t NVIM REFERENCE MANUAL',
+ '',
+ '',
+ 'Builtin functions\t\t*vimscript-functions* *builtin-functions*',
+ '',
+ 'For functions grouped by what they are used for see |function-list|.',
+ '',
+ '\t\t\t\t Type |gO| to see the table of contents.',
+ '==============================================================================',
+ '1. Details *builtin-function-details*',
+ '',
+ },
+ footer = {
+ '==============================================================================',
+ '2. Matching a pattern in a String *string-match*',
+ '',
+ 'This is common between several functions. A regexp pattern as explained at',
+ '|pattern| is normally used to find a match in the buffer lines. When a',
+ 'pattern is used to find a match in a String, almost everything works in the',
+ 'same way. The difference is that a String is handled like it is one line.',
+ 'When it contains a "\\n" character, this is not seen as a line break for the',
+ 'pattern. It can be matched with a "\\n" in the pattern, or with ".". Example:',
+ '>vim',
+ '\tlet a = "aaaa\\nxxxx"',
+ '\techo matchstr(a, "..\\n..")',
+ '\t" aa',
+ '\t" xx',
+ '\techo matchstr(a, "a.x")',
+ '\t" a',
+ '\t" x',
+ '',
+ 'Don\'t forget that "^" will only match at the first character of the String and',
+ '"$" at the last character of the string. They don\'t match after or before a',
+ '"\\n".',
+ '',
+ ' vim:tw=78:ts=8:noet:ft=help:norl:',
+ },
+ },
+ {
+ path = 'runtime/lua/vim/_meta/options.lua',
+ header = LUA_OPTION_META_HEADER,
+ funcs = get_option_meta,
+ render = render_option_meta,
+ },
+ {
+ path = 'runtime/doc/options.txt',
+ header = { '' },
+ from = 'A jump table for the options with a short description can be found at |Q_op|.',
+ footer = {
+ ' vim:tw=78:ts=8:noet:ft=help:norl:',
+ },
+ funcs = get_option_meta,
+ render = render_option_doc,
+ },
+ {
+ path = 'runtime/lua/vim/_meta/vvars.lua',
+ header = LUA_VVAR_META_HEADER,
+ funcs = get_vvar_meta,
+ render = render_vvar_meta,
+ },
+ {
+ path = 'runtime/doc/vvars.txt',
+ header = { '' },
+ from = 'Type |gO| to see the table of contents.',
+ footer = {
+ ' vim:tw=78:ts=8:noet:ft=help:norl:',
+ },
+ funcs = get_vvar_meta,
+ render = render_vvar_doc,
+ },
+}
+
+--- @param elem nvim.gen_eval_files.elem
+local function render(elem)
+ print('Rendering ' .. elem.path)
+ local from_lines = {} --- @type string[]
+ local from = elem.from
+ if from then
+ for line in io.lines(elem.path) do
+ from_lines[#from_lines + 1] = line
+ if line:match(from) then
+ break
+ end
+ end
+ end
+
+ local o = assert(io.open(elem.path, 'w'))
+
+ --- @param l string
+ local function write(l)
+ local l1 = l:gsub('%s+$', '')
+ o:write(l1)
+ o:write('\n')
+ end
+
+ for _, l in ipairs(from_lines) do
+ write(l)
+ end
+
+ for _, l in ipairs(elem.header or {}) do
+ write(l)
+ end
+
+ local funcs = elem.funcs()
+
+ --- @type string[]
+ local fnames = vim.tbl_keys(funcs)
+ table.sort(fnames)
+
+ for _, f in ipairs(fnames) do
+ elem.render(f, funcs[f], write)
+ end
+
+ for _, l in ipairs(elem.footer or {}) do
+ write(l)
+ end
+
+ o:close()
+end
+
+local function main()
+ for _, c in ipairs(CONFIG) do
+ render(c)
+ end
+end
+
+main()
diff --git a/src/gen/gen_events.lua b/src/gen/gen_events.lua
new file mode 100644
index 0000000000..77f766bb28
--- /dev/null
+++ b/src/gen/gen_events.lua
@@ -0,0 +1,42 @@
+local fileio_enum_file = arg[1]
+local names_file = arg[2]
+
+local auevents = require('nvim.auevents')
+local events = auevents.events
+
+local enum_tgt = io.open(fileio_enum_file, 'w')
+local names_tgt = io.open(names_file, 'w')
+
+enum_tgt:write([[
+// IWYU pragma: private, include "nvim/autocmd_defs.h"
+
+typedef enum auto_event {]])
+names_tgt:write([[
+static const struct event_name {
+ size_t len;
+ char *name;
+ int event;
+} event_names[] = {]])
+
+local aliases = 0
+for i, event in ipairs(events) do
+ enum_tgt:write(('\n EVENT_%s = %u,'):format(event[1]:upper(), i + aliases - 1))
+ -- Events with positive keys aren't allowed in 'eventignorewin'.
+ local event_int = ('%sEVENT_%s'):format(event[3] and '-' or '', event[1]:upper())
+ names_tgt:write(('\n {%u, "%s", %s},'):format(#event[1], event[1], event_int))
+ for _, alias in ipairs(event[2]) do
+ aliases = aliases + 1
+ names_tgt:write(('\n {%u, "%s", %s},'):format(#alias, alias, event_int))
+ enum_tgt:write(('\n EVENT_%s = %u,'):format(alias:upper(), i + aliases - 1))
+ end
+ if i == #events then -- Last item.
+ enum_tgt:write(('\n NUM_EVENTS = %u,'):format(i + aliases))
+ end
+end
+
+names_tgt:write('\n {0, NULL, (event_T)0},\n};\n')
+names_tgt:write('\nstatic AutoCmdVec autocmds[NUM_EVENTS] = { 0 };\n')
+names_tgt:close()
+
+enum_tgt:write('\n} event_T;\n')
+enum_tgt:close()
diff --git a/src/gen/gen_ex_cmds.lua b/src/gen/gen_ex_cmds.lua
new file mode 100644
index 0000000000..6c03e8fc4d
--- /dev/null
+++ b/src/gen/gen_ex_cmds.lua
@@ -0,0 +1,194 @@
+local includedir = arg[1]
+local autodir = arg[2]
+
+-- Will generate files ex_cmds_enum.generated.h with cmdidx_T enum
+-- and ex_cmds_defs.generated.h with main Ex commands definitions.
+
+local enumfname = includedir .. '/ex_cmds_enum.generated.h'
+local defsfname = autodir .. '/ex_cmds_defs.generated.h'
+
+local enumfile = io.open(enumfname, 'w')
+local defsfile = io.open(defsfname, 'w')
+
+local bit = require 'bit'
+local ex_cmds = require('nvim.ex_cmds')
+local defs = ex_cmds.cmds
+local flags = ex_cmds.flags
+
+local byte_a = string.byte('a')
+local byte_z = string.byte('z')
+local a_to_z = byte_z - byte_a + 1
+
+-- Table giving the index of the first command in cmdnames[] to lookup
+-- based on the first letter of a command.
+local cmdidxs1_out = string.format(
+ [[
+static const uint16_t cmdidxs1[%u] = {
+]],
+ a_to_z
+)
+-- Table giving the index of the first command in cmdnames[] to lookup
+-- based on the first 2 letters of a command.
+-- Values in cmdidxs2[c1][c2] are relative to cmdidxs1[c1] so that they
+-- fit in a byte.
+local cmdidxs2_out = string.format(
+ [[
+static const uint8_t cmdidxs2[%u][%u] = {
+ /* a b c d e f g h i j k l m n o p q r s t u v w x y z */
+]],
+ a_to_z,
+ a_to_z
+)
+
+enumfile:write([[
+// IWYU pragma: private, include "nvim/ex_cmds_defs.h"
+
+typedef enum CMD_index {
+]])
+defsfile:write(string.format(
+ [[
+#include "nvim/arglist.h"
+#include "nvim/autocmd.h"
+#include "nvim/buffer.h"
+#include "nvim/cmdhist.h"
+#include "nvim/debugger.h"
+#include "nvim/diff.h"
+#include "nvim/digraph.h"
+#include "nvim/eval.h"
+#include "nvim/eval/userfunc.h"
+#include "nvim/eval/vars.h"
+#include "nvim/ex_cmds.h"
+#include "nvim/ex_cmds2.h"
+#include "nvim/ex_docmd.h"
+#include "nvim/ex_eval.h"
+#include "nvim/ex_session.h"
+#include "nvim/help.h"
+#include "nvim/indent.h"
+#include "nvim/lua/executor.h"
+#include "nvim/lua/secure.h"
+#include "nvim/mapping.h"
+#include "nvim/mark.h"
+#include "nvim/match.h"
+#include "nvim/menu.h"
+#include "nvim/message.h"
+#include "nvim/ops.h"
+#include "nvim/option.h"
+#include "nvim/os/lang.h"
+#include "nvim/profile.h"
+#include "nvim/quickfix.h"
+#include "nvim/runtime.h"
+#include "nvim/sign.h"
+#include "nvim/spell.h"
+#include "nvim/spellfile.h"
+#include "nvim/syntax.h"
+#include "nvim/undo.h"
+#include "nvim/usercmd.h"
+#include "nvim/version.h"
+
+static const int command_count = %u;
+static CommandDefinition cmdnames[%u] = {
+]],
+ #defs,
+ #defs
+))
+local cmds, cmdidxs1, cmdidxs2 = {}, {}, {}
+for _, cmd in ipairs(defs) do
+ if bit.band(cmd.flags, flags.RANGE) == flags.RANGE then
+ assert(
+ cmd.addr_type ~= 'ADDR_NONE',
+ string.format('ex_cmds.lua:%s: Using RANGE with ADDR_NONE\n', cmd.command)
+ )
+ else
+ assert(
+ cmd.addr_type == 'ADDR_NONE',
+ string.format('ex_cmds.lua:%s: Missing ADDR_NONE\n', cmd.command)
+ )
+ end
+ if bit.band(cmd.flags, flags.DFLALL) == flags.DFLALL then
+ assert(
+ cmd.addr_type ~= 'ADDR_OTHER' and cmd.addr_type ~= 'ADDR_NONE',
+ string.format('ex_cmds.lua:%s: Missing misplaced DFLALL\n', cmd.command)
+ )
+ end
+ if bit.band(cmd.flags, flags.PREVIEW) == flags.PREVIEW then
+ assert(
+ cmd.preview_func ~= nil,
+ string.format('ex_cmds.lua:%s: Missing preview_func\n', cmd.command)
+ )
+ end
+ local enumname = cmd.enum or ('CMD_' .. cmd.command)
+ local byte_cmd = cmd.command:sub(1, 1):byte()
+ if byte_a <= byte_cmd and byte_cmd <= byte_z then
+ table.insert(cmds, cmd.command)
+ end
+ local preview_func
+ if cmd.preview_func then
+ preview_func = string.format('&%s', cmd.preview_func)
+ else
+ preview_func = 'NULL'
+ end
+ enumfile:write(' ' .. enumname .. ',\n')
+ defsfile:write(string.format(
+ [[
+ [%s] = {
+ .cmd_name = "%s",
+ .cmd_func = (ex_func_T)&%s,
+ .cmd_preview_func = %s,
+ .cmd_argt = %uL,
+ .cmd_addr_type = %s
+ },
+]],
+ enumname,
+ cmd.command,
+ cmd.func,
+ preview_func,
+ cmd.flags,
+ cmd.addr_type
+ ))
+end
+for i = #cmds, 1, -1 do
+ local cmd = cmds[i]
+ -- First and second characters of the command
+ local c1 = cmd:sub(1, 1)
+ cmdidxs1[c1] = i - 1
+ if cmd:len() >= 2 then
+ local c2 = cmd:sub(2, 2)
+ local byte_c2 = string.byte(c2)
+ if byte_a <= byte_c2 and byte_c2 <= byte_z then
+ if not cmdidxs2[c1] then
+ cmdidxs2[c1] = {}
+ end
+ cmdidxs2[c1][c2] = i - 1
+ end
+ end
+end
+for i = byte_a, byte_z do
+ local c1 = string.char(i)
+ cmdidxs1_out = cmdidxs1_out .. ' /* ' .. c1 .. ' */ ' .. cmdidxs1[c1] .. ',\n'
+ cmdidxs2_out = cmdidxs2_out .. ' /* ' .. c1 .. ' */ {'
+ for j = byte_a, byte_z do
+ local c2 = string.char(j)
+ cmdidxs2_out = cmdidxs2_out
+ .. ((cmdidxs2[c1] and cmdidxs2[c1][c2]) and string.format(
+ '%3d',
+ cmdidxs2[c1][c2] - cmdidxs1[c1]
+ ) or ' 0')
+ .. ','
+ end
+ cmdidxs2_out = cmdidxs2_out .. ' },\n'
+end
+enumfile:write([[
+ CMD_SIZE,
+ CMD_USER = -1,
+ CMD_USER_BUF = -2
+} cmdidx_T;
+]])
+defsfile:write(string.format(
+ [[
+};
+%s};
+%s};
+]],
+ cmdidxs1_out,
+ cmdidxs2_out
+))
diff --git a/src/gen/gen_filetype.lua b/src/gen/gen_filetype.lua
new file mode 100644
index 0000000000..18b53f1ea4
--- /dev/null
+++ b/src/gen/gen_filetype.lua
@@ -0,0 +1,209 @@
+local do_not_run = true
+if do_not_run then
+ print([[
+ This script was used to bootstrap the filetype patterns in runtime/lua/vim/filetype.lua. It
+ should no longer be used except for testing purposes. New filetypes, or changes to existing
+ filetypes, should be ported manually as part of the vim-patch process.
+ ]])
+ return
+end
+
+local filetype_vim = 'runtime/filetype.vim'
+local filetype_lua = 'runtime/lua/vim/filetype.lua'
+
+local keywords = {
+ ['for'] = true,
+ ['or'] = true,
+ ['and'] = true,
+ ['end'] = true,
+ ['do'] = true,
+ ['if'] = true,
+ ['while'] = true,
+ ['repeat'] = true,
+}
+
+local sections = {
+ extension = { str = {}, func = {} },
+ filename = { str = {}, func = {} },
+ pattern = { str = {}, func = {} },
+}
+
+local specialchars = '%*%?\\%$%[%]%{%}'
+
+local function add_pattern(pat, ft)
+ local ok = true
+
+ -- Patterns that start or end with { or } confuse splitting on commas and make parsing harder, so just skip those
+ if not string.find(pat, '^%{') and not string.find(pat, '%}$') then
+ for part in string.gmatch(pat, '[^,]+') do
+ if not string.find(part, '[' .. specialchars .. ']') then
+ if type(ft) == 'string' then
+ sections.filename.str[part] = ft
+ else
+ sections.filename.func[part] = ft
+ end
+ elseif string.match(part, '^%*%.[^%./' .. specialchars .. ']+$') then
+ if type(ft) == 'string' then
+ sections.extension.str[part:sub(3)] = ft
+ else
+ sections.extension.func[part:sub(3)] = ft
+ end
+ else
+ if string.match(part, '^%*/[^' .. specialchars .. ']+$') then
+ -- For patterns matching */some/pattern we want to easily match files
+ -- with path /some/pattern, so include those in filename detection
+ if type(ft) == 'string' then
+ sections.filename.str[part:sub(2)] = ft
+ else
+ sections.filename.func[part:sub(2)] = ft
+ end
+ end
+
+ if string.find(part, '^[%w-_.*?%[%]/]+$') then
+ local p = part:gsub('%.', '%%.'):gsub('%*', '.*'):gsub('%?', '.')
+ -- Insert into array to maintain order rather than setting
+ -- key-value directly
+ if type(ft) == 'string' then
+ sections.pattern.str[p] = ft
+ else
+ sections.pattern.func[p] = ft
+ end
+ else
+ ok = false
+ end
+ end
+ end
+ end
+
+ return ok
+end
+
+local function parse_line(line)
+ local pat, ft
+ pat, ft = line:match('^%s*au%a* Buf[%a,]+%s+(%S+)%s+setf%s+(%S+)')
+ if pat then
+ return add_pattern(pat, ft)
+ else
+ local func
+ pat, func = line:match('^%s*au%a* Buf[%a,]+%s+(%S+)%s+call%s+(%S+)')
+ if pat then
+ return add_pattern(pat, function()
+ return func
+ end)
+ end
+ end
+end
+
+local unparsed = {}
+local full_line
+for line in io.lines(filetype_vim) do
+ local cont = string.match(line, '^%s*\\%s*(.*)$')
+ if cont then
+ full_line = full_line .. ' ' .. cont
+ else
+ if full_line then
+ if not parse_line(full_line) and string.find(full_line, '^%s*au%a* Buf') then
+ table.insert(unparsed, full_line)
+ end
+ end
+ full_line = line
+ end
+end
+
+if #unparsed > 0 then
+ print('Failed to parse the following patterns:')
+ for _, v in ipairs(unparsed) do
+ print(v)
+ end
+end
+
+local function add_item(indent, key, ft)
+ if type(ft) == 'string' then
+ if string.find(key, '%A') or keywords[key] then
+ key = string.format('["%s"]', key)
+ end
+ return string.format([[%s%s = "%s",]], indent, key, ft)
+ elseif type(ft) == 'function' then
+ local func = ft()
+ if string.find(key, '%A') or keywords[key] then
+ key = string.format('["%s"]', key)
+ end
+ -- Right now only a single argument is supported, which covers
+ -- everything in filetype.vim as of this writing
+ local arg = string.match(func, '%((.*)%)$')
+ func = string.gsub(func, '%(.*$', '')
+ if arg == '' then
+ -- Function with no arguments, call the function directly
+ return string.format([[%s%s = function() vim.fn["%s"]() end,]], indent, key, func)
+ elseif string.match(arg, [[^(["']).*%1$]]) then
+ -- String argument
+ if func == 's:StarSetf' then
+ return string.format([[%s%s = starsetf(%s),]], indent, key, arg)
+ else
+ return string.format([[%s%s = function() vim.fn["%s"](%s) end,]], indent, key, func, arg)
+ end
+ elseif string.find(arg, '%(') then
+ -- Function argument
+ return string.format(
+ [[%s%s = function() vim.fn["%s"](vim.fn.%s) end,]],
+ indent,
+ key,
+ func,
+ arg
+ )
+ else
+ assert(false, arg)
+ end
+ end
+end
+
+do
+ local lines = {}
+ local start = false
+ for line in io.lines(filetype_lua) do
+ if line:match('^%s+-- END [A-Z]+$') then
+ start = false
+ end
+
+ if not start then
+ table.insert(lines, line)
+ end
+
+ local indent, section = line:match('^(%s+)-- BEGIN ([A-Z]+)$')
+ if section then
+ start = true
+ local t = sections[string.lower(section)]
+
+ local sorted = {}
+ for k, v in pairs(t.str) do
+ table.insert(sorted, { [k] = v })
+ end
+
+ table.sort(sorted, function(a, b)
+ return a[next(a)] < b[next(b)]
+ end)
+
+ for _, v in ipairs(sorted) do
+ local k = next(v)
+ table.insert(lines, add_item(indent, k, v[k]))
+ end
+
+ sorted = {}
+ for k, v in pairs(t.func) do
+ table.insert(sorted, { [k] = v })
+ end
+
+ table.sort(sorted, function(a, b)
+ return next(a) < next(b)
+ end)
+
+ for _, v in ipairs(sorted) do
+ local k = next(v)
+ table.insert(lines, add_item(indent, k, v[k]))
+ end
+ end
+ end
+ local f = io.open(filetype_lua, 'w')
+ f:write(table.concat(lines, '\n') .. '\n')
+ f:close()
+end
diff --git a/src/gen/gen_help_html.lua b/src/gen/gen_help_html.lua
new file mode 100644
index 0000000000..53a65fd65f
--- /dev/null
+++ b/src/gen/gen_help_html.lua
@@ -0,0 +1,1491 @@
+--- Converts Nvim :help files to HTML. Validates |tag| links and document syntax (parser errors).
+--
+-- USAGE (For CI/local testing purposes): Simply `make lintdoc` or `scripts/lintdoc.lua`, which
+-- basically does the following:
+-- 1. :helptags ALL
+-- 2. nvim -V1 -es +"lua require('scripts.gen_help_html').run_validate()" +q
+-- 3. nvim -V1 -es +"lua require('scripts.gen_help_html').test_gen()" +q
+--
+-- USAGE (GENERATE HTML):
+-- 1. `:helptags ALL` first; this script depends on vim.fn.taglist().
+-- 2. nvim -V1 -es --clean +"lua require('scripts.gen_help_html').gen('./runtime/doc', 'target/dir/')" +q
+-- - Read the docstring at gen().
+-- 3. cd target/dir/ && jekyll serve --host 0.0.0.0
+-- 4. Visit http://localhost:4000/…/help.txt.html
+--
+-- USAGE (VALIDATE):
+-- 1. nvim -V1 -es +"lua require('scripts.gen_help_html').validate('./runtime/doc')" +q
+-- - validate() is 10x faster than gen(), so it is used in CI.
+--
+-- SELF-TEST MODE:
+-- 1. nvim -V1 -es +"lua require('scripts.gen_help_html')._test()" +q
+--
+-- NOTES:
+-- * This script is used by the automation repo: https://github.com/neovim/doc
+-- * :helptags checks for duplicate tags, whereas this script checks _links_ (to tags).
+-- * gen() and validate() are the primary (programmatic) entrypoints. validate() only exists
+-- because gen() is too slow (~1 min) to run in per-commit CI.
+-- * visit_node() is the core function used by gen() to traverse the document tree and produce HTML.
+-- * visit_validate() is the core function used by validate().
+-- * Files in `new_layout` will be generated with a "flow" layout instead of preformatted/fixed-width layout.
+
+local tagmap = nil ---@type table<string, string>
+local helpfiles = nil ---@type string[]
+local invalid_links = {} ---@type table<string, any>
+local invalid_urls = {} ---@type table<string, any>
+local invalid_spelling = {} ---@type table<string, table<string, string>>
+local spell_dict = {
+ Neovim = 'Nvim',
+ NeoVim = 'Nvim',
+ neovim = 'Nvim',
+ lua = 'Lua',
+ VimL = 'Vimscript',
+ vimL = 'Vimscript',
+ viml = 'Vimscript',
+ ['tree-sitter'] = 'treesitter',
+ ['Tree-sitter'] = 'Treesitter',
+}
+--- specify the list of keywords to ignore (i.e. allow), or true to disable spell check completely.
+--- @type table<string, true|string[]>
+local spell_ignore_files = {
+ ['credits.txt'] = { 'Neovim' },
+ ['news.txt'] = { 'tree-sitter' }, -- in news, may refer to the upstream "tree-sitter" library
+ ['news-0.10.txt'] = { 'tree-sitter' },
+}
+local language = nil
+
+local M = {}
+
+-- These files are generated with "flow" layout (non fixed-width, wrapped text paragraphs).
+-- All other files are "legacy" files which require fixed-width layout.
+local new_layout = {
+ ['api.txt'] = true,
+ ['lsp.txt'] = true,
+ ['channel.txt'] = true,
+ ['deprecated.txt'] = true,
+ ['develop.txt'] = true,
+ ['dev_style.txt'] = true,
+ ['dev_theme.txt'] = true,
+ ['dev_tools.txt'] = true,
+ ['dev_vimpatch.txt'] = true,
+ ['editorconfig.txt'] = true,
+ ['faq.txt'] = true,
+ ['gui.txt'] = true,
+ ['intro.txt'] = true,
+ ['lua.txt'] = true,
+ ['luaref.txt'] = true,
+ ['news.txt'] = true,
+ ['news-0.9.txt'] = true,
+ ['news-0.10.txt'] = true,
+ ['nvim.txt'] = true,
+ ['provider.txt'] = true,
+ ['tui.txt'] = true,
+ ['ui.txt'] = true,
+ ['vim_diff.txt'] = true,
+}
+
+-- Map of new:old pages, to redirect renamed pages.
+local redirects = {
+ ['credits'] = 'backers',
+ ['tui'] = 'term',
+ ['terminal'] = 'nvim_terminal_emulator',
+}
+
+-- TODO: These known invalid |links| require an update to the relevant docs.
+local exclude_invalid = {
+ ["'string'"] = 'eval.txt',
+ Query = 'treesitter.txt',
+ matchit = 'vim_diff.txt',
+ ['set!'] = 'treesitter.txt',
+}
+
+-- False-positive "invalid URLs".
+local exclude_invalid_urls = {
+ ['http://'] = 'usr_23.txt',
+ ['http://.'] = 'usr_23.txt',
+ ['http://aspell.net/man-html/Affix-Compression.html'] = 'spell.txt',
+ ['http://aspell.net/man-html/Phonetic-Code.html'] = 'spell.txt',
+ ['http://canna.sourceforge.jp/'] = 'mbyte.txt',
+ ['http://gnuada.sourceforge.net'] = 'ft_ada.txt',
+ ['http://lua-users.org/wiki/StringLibraryTutorial'] = 'lua.txt',
+ ['http://michael.toren.net/code/'] = 'pi_tar.txt',
+ ['http://papp.plan9.de'] = 'syntax.txt',
+ ['http://wiki.services.openoffice.org/wiki/Dictionaries'] = 'spell.txt',
+ ['http://www.adapower.com'] = 'ft_ada.txt',
+ ['http://www.jclark.com/'] = 'quickfix.txt',
+ ['http://oldblog.antirez.com/post/redis-and-scripting.html'] = 'faq.txt',
+}
+
+-- Deprecated, brain-damaged files that I don't care about.
+local ignore_errors = {
+ ['pi_netrw.txt'] = true,
+ ['credits.txt'] = true,
+}
+
+local function tofile(fname, text)
+ local f = io.open(fname, 'w')
+ if not f then
+ error(('failed to write: %s'):format(f))
+ else
+ f:write(text)
+ f:close()
+ end
+end
+
+---@type fun(s: string): string
+local function html_esc(s)
+ return (s:gsub('&', '&amp;'):gsub('<', '&lt;'):gsub('>', '&gt;'))
+end
+
+local function url_encode(s)
+ -- Credit: tpope / vim-unimpaired
+ -- NOTE: these chars intentionally *not* escaped: ' ( )
+ return vim.fn.substitute(
+ vim.fn.iconv(s, 'latin1', 'utf-8'),
+ [=[[^A-Za-z0-9()'_.~-]]=],
+ [=[\="%".printf("%02X",char2nr(submatch(0)))]=],
+ 'g'
+ )
+end
+
+local function expandtabs(s)
+ return s:gsub('\t', (' '):rep(8)) --[[ @as string ]]
+end
+
+local function to_titlecase(s)
+ local text = ''
+ for w in vim.gsplit(s, '[ \t]+') do
+ text = ('%s %s%s'):format(text, vim.fn.toupper(w:sub(1, 1)), w:sub(2))
+ end
+ return text
+end
+
+local function to_heading_tag(text)
+ -- Prepend "_" to avoid conflicts with actual :help tags.
+ return text and string.format('_%s', vim.fn.tolower((text:gsub('%s+', '-')))) or 'unknown'
+end
+
+local function basename_noext(f)
+ return vim.fs.basename(f:gsub('%.txt', ''))
+end
+
+local function is_blank(s)
+ return not not s:find([[^[\t ]*$]])
+end
+
+---@type fun(s: string, dir?:0|1|2): string
+local function trim(s, dir)
+ return vim.fn.trim(s, '\r\t\n ', dir or 0)
+end
+
+--- Removes common punctuation from URLs.
+---
+--- TODO: fix this in the parser instead... https://github.com/neovim/tree-sitter-vimdoc
+---
+--- @param url string
+--- @return string, string (fixed_url, removed_chars) where `removed_chars` is in the order found in the input.
+local function fix_url(url)
+ local removed_chars = ''
+ local fixed_url = url
+ -- Remove up to one of each char from end of the URL, in this order.
+ for _, c in ipairs({ '.', ')' }) do
+ if fixed_url:sub(-1) == c then
+ removed_chars = c .. removed_chars
+ fixed_url = fixed_url:sub(1, -2)
+ end
+ end
+ return fixed_url, removed_chars
+end
+
+--- Checks if a given line is a "noise" line that doesn't look good in HTML form.
+local function is_noise(line, noise_lines)
+ if
+ -- First line is always noise.
+ (noise_lines ~= nil and vim.tbl_count(noise_lines) == 0)
+ or line:find('Type .*gO.* to see the table of contents')
+ -- Title line of traditional :help pages.
+ -- Example: "NVIM REFERENCE MANUAL by ..."
+ or line:find([[^%s*N?VIM[ \t]*REFERENCE[ \t]*MANUAL]])
+ -- First line of traditional :help pages.
+ -- Example: "*api.txt* Nvim"
+ or line:find('%s*%*?[a-zA-Z]+%.txt%*?%s+N?[vV]im%s*$')
+ -- modeline
+ -- Example: "vim:tw=78:ts=8:sw=4:sts=4:et:ft=help:norl:"
+ or line:find('^%s*vim?%:.*ft=help')
+ or line:find('^%s*vim?%:.*filetype=help')
+ or line:find('[*>]local%-additions[*<]')
+ then
+ -- table.insert(stats.noise_lines, getbuflinestr(root, opt.buf, 0))
+ table.insert(noise_lines or {}, line)
+ return true
+ end
+ return false
+end
+
+--- Creates a github issue URL at neovim/tree-sitter-vimdoc with prefilled content.
+--- @return string
+local function get_bug_url_vimdoc(fname, to_fname, sample_text)
+ local this_url = string.format('https://neovim.io/doc/user/%s', vim.fs.basename(to_fname))
+ local bug_url = (
+ 'https://github.com/neovim/tree-sitter-vimdoc/issues/new?labels=bug&title=parse+error%3A+'
+ .. vim.fs.basename(fname)
+ .. '+&body=Found+%60tree-sitter-vimdoc%60+parse+error+at%3A+'
+ .. this_url
+ .. '%0D%0DContext%3A%0D%0D%60%60%60%0D'
+ .. url_encode(sample_text)
+ .. '%0D%60%60%60'
+ )
+ return bug_url
+end
+
+--- Creates a github issue URL at neovim/neovim with prefilled content.
+--- @return string
+local function get_bug_url_nvim(fname, to_fname, sample_text, token_name)
+ local this_url = string.format('https://neovim.io/doc/user/%s', vim.fs.basename(to_fname))
+ local bug_url = (
+ 'https://github.com/neovim/neovim/issues/new?labels=bug&title=user+docs+HTML%3A+'
+ .. vim.fs.basename(fname)
+ .. '+&body=%60gen_help_html.lua%60+problem+at%3A+'
+ .. this_url
+ .. '%0D'
+ .. (token_name and '+unhandled+token%3A+%60' .. token_name .. '%60' or '')
+ .. '%0DContext%3A%0D%0D%60%60%60%0D'
+ .. url_encode(sample_text)
+ .. '%0D%60%60%60'
+ )
+ return bug_url
+end
+
+--- Gets a "foo.html" name from a "foo.txt" helpfile name.
+local function get_helppage(f)
+ if not f then
+ return nil
+ end
+ -- Special case: help.txt is the "main landing page" of :help files, not index.txt.
+ if f == 'index.txt' then
+ return 'vimindex.html'
+ elseif f == 'help.txt' then
+ return 'index.html'
+ end
+
+ return (f:gsub('%.txt$', '')) .. '.html'
+end
+
+--- Counts leading spaces (tab=8) to decide the indent size of multiline text.
+---
+--- Blank lines (empty or whitespace-only) are ignored.
+local function get_indent(s)
+ local min_indent = nil
+ for line in vim.gsplit(s, '\n') do
+ if line and not is_blank(line) then
+ local ws = expandtabs(line:match('^%s+') or '')
+ min_indent = (not min_indent or ws:len() < min_indent) and ws:len() or min_indent
+ end
+ end
+ return min_indent or 0
+end
+
+--- Removes the common indent level, after expanding tabs to 8 spaces.
+local function trim_indent(s)
+ local indent_size = get_indent(s)
+ local trimmed = ''
+ for line in vim.gsplit(s, '\n') do
+ line = expandtabs(line)
+ trimmed = ('%s%s\n'):format(trimmed, line:sub(indent_size + 1))
+ end
+ return trimmed:sub(1, -2)
+end
+
+--- Gets raw buffer text in the node's range (+/- an offset), as a newline-delimited string.
+---@param node TSNode
+---@param bufnr integer
+---@param offset integer
+local function getbuflinestr(node, bufnr, offset)
+ local line1, _, line2, _ = node:range()
+ line1 = line1 - offset
+ line2 = line2 + offset
+ local lines = vim.fn.getbufline(bufnr, line1 + 1, line2 + 1)
+ return table.concat(lines, '\n')
+end
+
+--- Gets the whitespace just before `node` from the raw buffer text.
+--- Needed for preformatted `old` lines.
+---@param node TSNode
+---@param bufnr integer
+---@return string
+local function getws(node, bufnr)
+ local line1, c1, line2, _ = node:range()
+ ---@type string
+ local raw = vim.fn.getbufline(bufnr, line1 + 1, line2 + 1)[1]
+ local text_before = raw:sub(1, c1)
+ local leading_ws = text_before:match('%s+$') or ''
+ return leading_ws
+end
+
+local function get_tagname(node, bufnr)
+ local text = vim.treesitter.get_node_text(node, bufnr)
+ local tag = (node:type() == 'optionlink' or node:parent():type() == 'optionlink')
+ and ("'%s'"):format(text)
+ or text
+ local helpfile = vim.fs.basename(tagmap[tag]) or nil -- "api.txt"
+ local helppage = get_helppage(helpfile) -- "api.html"
+ return helppage, tag
+end
+
+--- Returns true if the given invalid tagname is a false positive.
+local function ignore_invalid(s)
+ return not not (
+ exclude_invalid[s]
+ -- Strings like |~/====| appear in various places and the parser thinks they are links, but they
+ -- are just table borders.
+ or s:find('===')
+ or s:find('%-%-%-')
+ )
+end
+
+local function ignore_parse_error(fname, s)
+ if ignore_errors[vim.fs.basename(fname)] then
+ return true
+ end
+ -- Ignore parse errors for unclosed tag.
+ -- This is common in vimdocs and is treated as plaintext by :help.
+ return s:find("^[`'|*]")
+end
+
+---@param node TSNode
+local function has_ancestor(node, ancestor_name)
+ local p = node ---@type TSNode?
+ while p do
+ p = p:parent()
+ if not p or p:type() == 'help_file' then
+ break
+ elseif p:type() == ancestor_name then
+ return true
+ end
+ end
+ return false
+end
+
+--- Gets the first matching child node matching `name`.
+---@param node TSNode
+local function first(node, name)
+ for c, _ in node:iter_children() do
+ if c:named() and c:type() == name then
+ return c
+ end
+ end
+ return nil
+end
+
+local function validate_link(node, bufnr, fname)
+ local helppage, tagname = get_tagname(node:child(1), bufnr)
+ local ignored = false
+ if not tagmap[tagname] then
+ ignored = has_ancestor(node, 'column_heading') or node:has_error() or ignore_invalid(tagname)
+ if not ignored then
+ invalid_links[tagname] = vim.fs.basename(fname)
+ end
+ end
+ return helppage, tagname, ignored
+end
+
+--- TODO: port the logic from scripts/check_urls.vim
+local function validate_url(text, fname)
+ local ignored = false
+ if ignore_errors[vim.fs.basename(fname)] then
+ ignored = true
+ elseif text:find('http%:') and not exclude_invalid_urls[text] then
+ invalid_urls[text] = vim.fs.basename(fname)
+ end
+ return ignored
+end
+
+--- Traverses the tree at `root` and checks that |tag| links point to valid helptags.
+---@param root TSNode
+---@param level integer
+---@param lang_tree TSTree
+---@param opt table
+---@param stats table
+local function visit_validate(root, level, lang_tree, opt, stats)
+ level = level or 0
+ local node_name = (root.named and root:named()) and root:type() or nil
+ -- Parent kind (string).
+ local parent = root:parent() and root:parent():type() or nil
+ local toplevel = level < 1
+ local function node_text(node)
+ return vim.treesitter.get_node_text(node or root, opt.buf)
+ end
+ local text = trim(node_text())
+
+ if root:child_count() > 0 then
+ for node, _ in root:iter_children() do
+ if node:named() then
+ visit_validate(node, level + 1, lang_tree, opt, stats)
+ end
+ end
+ end
+
+ if node_name == 'ERROR' then
+ if ignore_parse_error(opt.fname, text) then
+ return
+ end
+ -- Store the raw text to give context to the error report.
+ local sample_text = not toplevel and getbuflinestr(root, opt.buf, 0) or '[top level!]'
+ -- Flatten the sample text to a single, truncated line.
+ sample_text = vim.trim(sample_text):gsub('[\t\n]', ' '):sub(1, 80)
+ table.insert(stats.parse_errors, sample_text)
+ elseif
+ (node_name == 'word' or node_name == 'uppercase_name')
+ and (not vim.tbl_contains({ 'codespan', 'taglink', 'tag' }, parent))
+ then
+ local text_nopunct = vim.fn.trim(text, '.,', 0) -- Ignore some punctuation.
+ local fname_basename = assert(vim.fs.basename(opt.fname))
+ if spell_dict[text_nopunct] then
+ local should_ignore = (
+ spell_ignore_files[fname_basename] == true
+ or vim.tbl_contains(
+ (spell_ignore_files[fname_basename] or {}) --[[ @as string[] ]],
+ text_nopunct
+ )
+ )
+ if not should_ignore then
+ invalid_spelling[text_nopunct] = invalid_spelling[text_nopunct] or {}
+ invalid_spelling[text_nopunct][fname_basename] = node_text(root:parent())
+ end
+ end
+ elseif node_name == 'url' then
+ local fixed_url, _ = fix_url(trim(text))
+ validate_url(fixed_url, opt.fname)
+ elseif node_name == 'taglink' or node_name == 'optionlink' then
+ local _, _, _ = validate_link(root, opt.buf, opt.fname)
+ end
+end
+
+-- Fix tab alignment issues caused by concealed characters like |, `, * in tags
+-- and code blocks.
+---@param text string
+---@param next_node_text string
+local function fix_tab_after_conceal(text, next_node_text)
+ -- Vim tabs take into account the two concealed characters even though they
+ -- are invisible, so we need to add back in the two spaces if this is
+ -- followed by a tab to make the tab alignment to match Vim's behavior.
+ if string.sub(next_node_text, 1, 1) == '\t' then
+ text = text .. ' '
+ end
+ return text
+end
+
+---@class (exact) nvim.gen_help_html.heading
+---@field name string
+---@field subheadings nvim.gen_help_html.heading[]
+---@field tag string
+
+-- Generates HTML from node `root` recursively.
+---@param root TSNode
+---@param level integer
+---@param lang_tree TSTree
+---@param headings nvim.gen_help_html.heading[]
+---@param opt table
+---@param stats table
+local function visit_node(root, level, lang_tree, headings, opt, stats)
+ level = level or 0
+
+ local node_name = (root.named and root:named()) and root:type() or nil
+ -- Previous sibling kind (string).
+ local prev = root:prev_sibling()
+ and (root:prev_sibling().named and root:prev_sibling():named())
+ and root:prev_sibling():type()
+ or nil
+ -- Next sibling kind (string).
+ local next_ = root:next_sibling()
+ and (root:next_sibling().named and root:next_sibling():named())
+ and root:next_sibling():type()
+ or nil
+ -- Parent kind (string).
+ local parent = root:parent() and root:parent():type() or nil
+ -- Gets leading whitespace of `node`.
+ local function ws(node)
+ node = node or root
+ local ws_ = getws(node, opt.buf)
+ -- XXX: first node of a (line) includes whitespace, even after
+ -- https://github.com/neovim/tree-sitter-vimdoc/pull/31 ?
+ if ws_ == '' then
+ ws_ = vim.treesitter.get_node_text(node, opt.buf):match('^%s+') or ''
+ end
+ return ws_
+ end
+ local function node_text(node, ws_)
+ node = node or root
+ ws_ = (ws_ == nil or ws_ == true) and getws(node, opt.buf) or ''
+ return string.format('%s%s', ws_, vim.treesitter.get_node_text(node, opt.buf))
+ end
+
+ local text = ''
+ local trimmed ---@type string
+ if root:named_child_count() == 0 or node_name == 'ERROR' then
+ text = node_text()
+ trimmed = html_esc(trim(text))
+ text = html_esc(text)
+ else
+ -- Process children and join them with whitespace.
+ for node, _ in root:iter_children() do
+ if node:named() then
+ local r = visit_node(node, level + 1, lang_tree, headings, opt, stats)
+ text = string.format('%s%s', text, r)
+ end
+ end
+ trimmed = trim(text)
+ end
+
+ if node_name == 'help_file' then -- root node
+ return text
+ elseif node_name == 'url' then
+ local fixed_url, removed_chars = fix_url(trimmed)
+ return ('%s<a href="%s">%s</a>%s'):format(ws(), fixed_url, fixed_url, removed_chars)
+ elseif node_name == 'word' or node_name == 'uppercase_name' then
+ return text
+ elseif node_name == 'note' then
+ return ('<b>%s</b>'):format(text)
+ elseif node_name == 'h1' or node_name == 'h2' or node_name == 'h3' then
+ if is_noise(text, stats.noise_lines) then
+ return '' -- Discard common "noise" lines.
+ end
+ -- Remove tags from ToC text.
+ local heading_node = first(root, 'heading')
+ local hname = trim(node_text(heading_node):gsub('%*.*%*', ''))
+ if not heading_node or hname == '' then
+ return '' -- Spurious "===" or "---" in the help doc.
+ end
+
+ -- Generate an anchor id from the heading text.
+ local tagname = to_heading_tag(hname)
+ if node_name == 'h1' or #headings == 0 then
+ ---@type nvim.gen_help_html.heading
+ local heading = { name = hname, subheadings = {}, tag = tagname }
+ headings[#headings + 1] = heading
+ else
+ table.insert(
+ headings[#headings].subheadings,
+ { name = hname, subheadings = {}, tag = tagname }
+ )
+ end
+ local el = node_name == 'h1' and 'h2' or 'h3'
+ return ('<%s id="%s" class="help-heading">%s</%s>\n'):format(el, tagname, trimmed, el)
+ elseif node_name == 'heading' then
+ return trimmed
+ elseif node_name == 'column_heading' or node_name == 'column_name' then
+ if root:has_error() then
+ return text
+ end
+ return ('<div class="help-column_heading">%s</div>'):format(text)
+ elseif node_name == 'block' then
+ if is_blank(text) then
+ return ''
+ end
+ if opt.old then
+ -- XXX: Treat "old" docs as preformatted: they use indentation for layout.
+ -- Trim trailing newlines to avoid too much whitespace between divs.
+ return ('<div class="old-help-para">%s</div>\n'):format(trim(text, 2))
+ end
+ return string.format('<div class="help-para">\n%s\n</div>\n', text)
+ elseif node_name == 'line' then
+ if
+ (parent ~= 'codeblock' or parent ~= 'code')
+ and (is_blank(text) or is_noise(text, stats.noise_lines))
+ then
+ return '' -- Discard common "noise" lines.
+ end
+ -- XXX: Avoid newlines (too much whitespace) after block elements in old (preformatted) layout.
+ local div = opt.old
+ and root:child(0)
+ and vim.list_contains({ 'column_heading', 'h1', 'h2', 'h3' }, root:child(0):type())
+ return string.format('%s%s', div and trim(text) or text, div and '' or '\n')
+ elseif node_name == 'line_li' then
+ local sib = root:prev_sibling()
+ local prev_li = sib and sib:type() == 'line_li'
+
+ if not prev_li then
+ opt.indent = 1
+ else
+ -- The previous listitem _sibling_ is _logically_ the _parent_ if it is indented less.
+ local parent_indent = get_indent(node_text(sib))
+ local this_indent = get_indent(node_text())
+ if this_indent > parent_indent then
+ opt.indent = opt.indent + 1
+ elseif this_indent < parent_indent then
+ opt.indent = math.max(1, opt.indent - 1)
+ end
+ end
+ local margin = opt.indent == 1 and '' or ('margin-left: %drem;'):format((1.5 * opt.indent))
+
+ return string.format('<div class="help-li" style="%s">%s</div>', margin, text)
+ elseif node_name == 'taglink' or node_name == 'optionlink' then
+ local helppage, tagname, ignored = validate_link(root, opt.buf, opt.fname)
+ if ignored then
+ return text
+ end
+ local s = ('%s<a href="%s#%s">%s</a>'):format(
+ ws(),
+ helppage,
+ url_encode(tagname),
+ html_esc(tagname)
+ )
+ if opt.old and node_name == 'taglink' then
+ s = fix_tab_after_conceal(s, node_text(root:next_sibling()))
+ end
+ return s
+ elseif vim.list_contains({ 'codespan', 'keycode' }, node_name) then
+ if root:has_error() then
+ return text
+ end
+ local s = ('%s<code>%s</code>'):format(ws(), trimmed)
+ if opt.old and node_name == 'codespan' then
+ s = fix_tab_after_conceal(s, node_text(root:next_sibling()))
+ end
+ return s
+ elseif node_name == 'argument' then
+ return ('%s<code>{%s}</code>'):format(ws(), text)
+ elseif node_name == 'codeblock' then
+ return text
+ elseif node_name == 'language' then
+ language = node_text(root)
+ return ''
+ elseif node_name == 'code' then -- Highlighted codeblock (child).
+ if is_blank(text) then
+ return ''
+ end
+ local code ---@type string
+ if language then
+ code = ('<pre><code class="language-%s">%s</code></pre>'):format(
+ language,
+ trim(trim_indent(text), 2)
+ )
+ language = nil
+ else
+ code = ('<pre>%s</pre>'):format(trim(trim_indent(text), 2))
+ end
+ return code
+ elseif node_name == 'tag' then -- anchor, h4 pseudo-heading
+ if root:has_error() then
+ return text
+ end
+ local in_heading = vim.list_contains({ 'h1', 'h2', 'h3' }, parent)
+ local h4 = not in_heading and not next_ and get_indent(node_text()) > 8 -- h4 pseudo-heading
+ local cssclass = h4 and 'help-tag-right' or 'help-tag'
+ local tagname = node_text(root:child(1), false)
+ if vim.tbl_count(stats.first_tags) < 2 then
+ -- Force the first 2 tags in the doc to be anchored at the main heading.
+ table.insert(stats.first_tags, tagname)
+ return ''
+ end
+ local el = 'span'
+ local encoded_tagname = url_encode(tagname)
+ local s = ('%s<%s id="%s" class="%s"><a href="#%s">%s</a></%s>'):format(
+ ws(),
+ el,
+ encoded_tagname,
+ cssclass,
+ encoded_tagname,
+ trimmed,
+ el
+ )
+ if opt.old then
+ s = fix_tab_after_conceal(s, node_text(root:next_sibling()))
+ end
+
+ if in_heading and prev ~= 'tag' then
+ -- Start the <span> container for tags in a heading.
+ -- This makes "justify-content:space-between" right-align the tags.
+ -- <h2>foo bar<span>tag1 tag2</span></h2>
+ return string.format('<span class="help-heading-tags">%s', s)
+ elseif in_heading and next_ == nil then
+ -- End the <span> container for tags in a heading.
+ return string.format('%s</span>', s)
+ end
+ return s .. (h4 and '<br>' or '') -- HACK: <br> avoids h4 pseudo-heading mushing with text.
+ elseif node_name == 'delimiter' or node_name == 'modeline' then
+ return ''
+ elseif node_name == 'ERROR' then
+ if ignore_parse_error(opt.fname, trimmed) then
+ return text
+ end
+
+ -- Store the raw text to give context to the bug report.
+ local sample_text = level > 0 and getbuflinestr(root, opt.buf, 3) or '[top level!]'
+ table.insert(stats.parse_errors, sample_text)
+ return ('<a class="parse-error" target="_blank" title="Report bug... (parse error)" href="%s">%s</a>'):format(
+ get_bug_url_vimdoc(opt.fname, opt.to_fname, sample_text),
+ trimmed
+ )
+ else -- Unknown token.
+ local sample_text = level > 0 and getbuflinestr(root, opt.buf, 3) or '[top level!]'
+ return ('<a class="unknown-token" target="_blank" title="Report bug... (unhandled token "%s")" href="%s">%s</a>'):format(
+ node_name,
+ get_bug_url_nvim(opt.fname, opt.to_fname, sample_text, node_name),
+ trimmed
+ ),
+ ('unknown-token:"%s"'):format(node_name)
+ end
+end
+
+--- @param dir string e.g. '$VIMRUNTIME/doc'
+--- @param include string[]|nil
+--- @return string[]
+local function get_helpfiles(dir, include)
+ local rv = {}
+ for f, type in vim.fs.dir(dir) do
+ if
+ vim.endswith(f, '.txt')
+ and type == 'file'
+ and (not include or vim.list_contains(include, f))
+ then
+ local fullpath = vim.fn.fnamemodify(('%s/%s'):format(dir, f), ':p')
+ table.insert(rv, fullpath)
+ end
+ end
+ return rv
+end
+
+--- Populates the helptags map.
+local function get_helptags(help_dir)
+ local m = {}
+ -- Load a random help file to convince taglist() to do its job.
+ vim.cmd(string.format('split %s/api.txt', help_dir))
+ vim.cmd('lcd %:p:h')
+ for _, item in ipairs(vim.fn.taglist('.*')) do
+ if vim.endswith(item.filename, '.txt') then
+ m[item.name] = item.filename
+ end
+ end
+ vim.cmd('q!')
+ return m
+end
+
+--- Use the vimdoc parser defined in the build, not whatever happens to be installed on the system.
+local function ensure_runtimepath()
+ if not vim.o.runtimepath:find('build/lib/nvim/') then
+ vim.cmd [[set runtimepath^=./build/lib/nvim/]]
+ end
+end
+
+--- Opens `fname` (or `text`, if given) in a buffer and gets a treesitter parser for the buffer contents.
+---
+--- @param fname string :help file to parse
+--- @param text string? :help file contents
+--- @param parser_path string? path to non-default vimdoc.so
+--- @return vim.treesitter.LanguageTree, integer (lang_tree, bufnr)
+local function parse_buf(fname, text, parser_path)
+ local buf ---@type integer
+ if text then
+ vim.cmd('split new') -- Text contents.
+ vim.api.nvim_put(vim.split(text, '\n'), '', false, false)
+ vim.cmd('setfiletype help')
+ -- vim.treesitter.language.add('vimdoc')
+ buf = vim.api.nvim_get_current_buf()
+ elseif type(fname) == 'string' then
+ vim.cmd('split ' .. vim.fn.fnameescape(fname)) -- Filename.
+ buf = vim.api.nvim_get_current_buf()
+ else
+ -- Left for debugging
+ ---@diagnostic disable-next-line: no-unknown
+ buf = fname
+ vim.cmd('sbuffer ' .. tostring(fname)) -- Buffer number.
+ end
+ if parser_path then
+ vim.treesitter.language.add('vimdoc', { path = parser_path })
+ end
+ local lang_tree = assert(vim.treesitter.get_parser(buf, nil, { error = false }))
+ return lang_tree, buf
+end
+
+--- Validates one :help file `fname`:
+--- - checks that |tag| links point to valid helptags.
+--- - recursively counts parse errors ("ERROR" nodes)
+---
+--- @param fname string help file to validate
+--- @param parser_path string? path to non-default vimdoc.so
+--- @return { invalid_links: number, parse_errors: string[] }
+local function validate_one(fname, parser_path)
+ local stats = {
+ parse_errors = {},
+ }
+ local lang_tree, buf = parse_buf(fname, nil, parser_path)
+ for _, tree in ipairs(lang_tree:trees()) do
+ visit_validate(tree:root(), 0, tree, { buf = buf, fname = fname }, stats)
+ end
+ lang_tree:destroy()
+ vim.cmd.close()
+ return stats
+end
+
+--- Generates HTML from one :help file `fname` and writes the result to `to_fname`.
+---
+--- @param fname string Source :help file.
+--- @param text string|nil Source :help file contents, or nil to read `fname`.
+--- @param to_fname string Destination .html file
+--- @param old boolean Preformat paragraphs (for old :help files which are full of arbitrary whitespace)
+--- @param parser_path string? path to non-default vimdoc.so
+---
+--- @return string html
+--- @return table stats
+local function gen_one(fname, text, to_fname, old, commit, parser_path)
+ local stats = {
+ noise_lines = {},
+ parse_errors = {},
+ first_tags = {}, -- Track the first few tags in doc.
+ }
+ local lang_tree, buf = parse_buf(fname, text, parser_path)
+ ---@type nvim.gen_help_html.heading[]
+ local headings = {} -- Headings (for ToC). 2-dimensional: h1 contains h2/h3.
+ local title = to_titlecase(basename_noext(fname))
+
+ local html = ([[
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <meta name="description" content="Neovim user documentation">
+
+ <!-- algolia docsearch https://docsearch.algolia.com/docs/docsearch-v3/ -->
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@docsearch/css@3" />
+ <link rel="preconnect" href="https://X185E15FPG-dsn.algolia.net" crossorigin />
+
+ <link href="/css/bootstrap.min.css" rel="stylesheet">
+ <link href="/css/main.css" rel="stylesheet">
+ <link href="help.css" rel="stylesheet">
+ <link href="/highlight/styles/neovim.min.css" rel="stylesheet">
+
+ <script src="/highlight/highlight.min.js"></script>
+ <script>hljs.highlightAll();</script>
+ <title>%s - Neovim docs</title>
+ </head>
+ <body>
+ ]]):format(title)
+
+ local logo_svg = [[
+ <svg xmlns="http://www.w3.org/2000/svg" role="img" width="173" height="50" viewBox="0 0 742 214" aria-label="Neovim">
+ <title>Neovim</title>
+ <defs>
+ <linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="a">
+ <stop stop-color="#16B0ED" stop-opacity=".8" offset="0%" />
+ <stop stop-color="#0F59B2" stop-opacity=".837" offset="100%" />
+ </linearGradient>
+ <linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="b">
+ <stop stop-color="#7DB643" offset="0%" />
+ <stop stop-color="#367533" offset="100%" />
+ </linearGradient>
+ <linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="c">
+ <stop stop-color="#88C649" stop-opacity=".8" offset="0%" />
+ <stop stop-color="#439240" stop-opacity=".84" offset="100%" />
+ </linearGradient>
+ </defs>
+ <g fill="none" fill-rule="evenodd">
+ <path
+ d="M.027 45.459L45.224-.173v212.171L.027 166.894V45.459z"
+ fill="url(#a)"
+ transform="translate(1 1)"
+ />
+ <path
+ d="M129.337 45.89L175.152-.149l-.928 212.146-45.197-45.104.31-121.005z"
+ fill="url(#b)"
+ transform="matrix(-1 0 0 1 305 1)"
+ />
+ <path
+ d="M45.194-.137L162.7 179.173l-32.882 32.881L12.25 33.141 45.194-.137z"
+ fill="url(#c)"
+ transform="translate(1 1)"
+ />
+ <path
+ d="M46.234 84.032l-.063 7.063-36.28-53.563 3.36-3.422 32.983 49.922z"
+ fill-opacity=".13"
+ fill="#000"
+ />
+ <g fill="#444">
+ <path
+ d="M227 154V64.44h4.655c1.55 0 2.445.75 2.685 2.25l.806 13.502c4.058-5.16 8.786-9.316 14.188-12.466 5.4-3.15 11.413-4.726 18.037-4.726 4.893 0 9.205.781 12.935 2.34 3.729 1.561 6.817 3.811 9.264 6.751 2.448 2.942 4.297 6.48 5.55 10.621 1.253 4.14 1.88 8.821 1.88 14.042V154h-8.504V96.754c0-8.402-1.91-14.987-5.729-19.757-3.82-4.771-9.667-7.156-17.544-7.156-5.851 0-11.28 1.516-16.292 4.545-5.013 3.032-9.489 7.187-13.427 12.467V154H227zM350.624 63c5.066 0 9.755.868 14.069 2.605 4.312 1.738 8.052 4.268 11.219 7.592s5.638 7.412 7.419 12.264C385.11 90.313 386 95.883 386 102.17c0 1.318-.195 2.216-.588 2.696-.393.48-1.01.719-1.851.719h-64.966v1.70c0 6.708.784 12.609 2.353 17.7 1.567 5.09 3.8 9.357 6.695 12.802 2.895 3.445 6.393 6.034 10.495 7.771 4.1 1.738 8.686 2.606 13.752 2.606 4.524 0 8.446-.494 11.762-1.483 3.317-.988 6.108-2.097 8.37-3.324 2.261-1.227 4.056-2.336 5.383-3.324 1.326-.988 2.292-1.482 2.895-1.482.784 0 1.388.3 1.81.898l2.352 2.875c-1.448 1.797-3.362 3.475-5.745 5.031-2.383 1.558-5.038 2.891-7.962 3.998-2.926 1.109-6.062 1.991-9.41 2.65a52.21 52.21 0 01-10.088.989c-6.152 0-11.762-1.064-16.828-3.19-5.067-2.125-9.415-5.225-13.043-9.298-3.63-4.074-6.435-9.06-8.415-14.96C310.99 121.655 310 114.9 310 107.294c0-6.408.92-12.323 2.76-17.744 1.84-5.421 4.493-10.093 7.961-14.016 3.467-3.922 7.72-6.991 12.758-9.209C338.513 64.11 344.229 63 350.624 63zm.573 6c-4.696 0-8.904.702-12.623 2.105-3.721 1.404-6.936 3.421-9.65 6.053-2.713 2.631-4.908 5.79-6.586 9.474S319.55 94.439 319 99h60c0-4.679-.672-8.874-2.013-12.588-1.343-3.712-3.232-6.856-5.67-9.43-2.44-2.571-5.367-4.545-8.782-5.92-3.413-1.374-7.192-2.062-11.338-2.062zM435.546 63c6.526 0 12.368 1.093 17.524 3.28 5.154 2.186 9.5 5.286 13.04 9.298 3.538 4.013 6.238 8.85 8.099 14.51 1.861 5.66 2.791 11.994 2.791 19.002 0 7.008-.932 13.327-2.791 18.957-1.861 5.631-4.561 10.452-8.099 14.465-3.54 4.012-7.886 7.097-13.04 9.254-5.156 2.156-10.998 3.234-17.524 3.234-6.529 0-12.369-1.078-17.525-3.234-5.155-2.157-9.517-5.242-13.085-9.254-3.57-4.013-6.285-8.836-8.145-14.465-1.861-5.63-2.791-11.95-2.791-18.957 0-7.008.93-13.342 2.791-19.002 1.861-5.66 4.576-10.496 8.145-14.51 3.568-4.012 7.93-7.112 13.085-9.299C423.177 64.094 429.017 63 435.546 63zm-.501 86c5.341 0 10.006-.918 13.997-2.757 3.99-1.838 7.32-4.474 9.992-7.909 2.67-3.435 4.664-7.576 5.986-12.428 1.317-4.85 1.98-10.288 1.98-16.316 0-5.965-.66-11.389-1.98-16.27-1.322-4.88-3.316-9.053-5.986-12.519-2.67-3.463-6-6.13-9.992-7.999-3.991-1.867-8.657-2.802-13.997-2.802s-10.008.935-13.997 2.802c-3.991 1.87-7.322 4.536-9.992 8-2.671 3.465-4.68 7.637-6.03 12.518-1.35 4.881-2.026 10.305-2.026 16.27 0 6.026.675 11.465 2.025 16.316 1.35 4.852 3.36 8.993 6.031 12.428 2.67 3.435 6 6.07 9.992 7.91 3.99 1.838 8.656 2.756 13.997 2.756z"
+ fill="currentColor"
+ />
+ <path
+ d="M530.57 152h-20.05L474 60h18.35c1.61 0 2.967.39 4.072 1.166 1.103.778 1.865 1.763 2.283 2.959l17.722 49.138a92.762 92.762 0 012.551 8.429c.686 2.751 1.298 5.5 1.835 8.25.537-2.75 1.148-5.499 1.835-8.25a77.713 77.713 0 012.64-8.429l18.171-49.138c.417-1.196 1.164-2.181 2.238-2.96 1.074-.776 2.356-1.165 3.849-1.165H567l-36.43 92zM572 61h23v92h-23zM610 153V60.443h13.624c2.887 0 4.78 1.354 5.682 4.06l1.443 6.856a52.7 52.7 0 015.097-4.962 32.732 32.732 0 015.683-3.879 30.731 30.731 0 016.496-2.57c2.314-.632 4.855-.948 7.624-.948 5.832 0 10.63 1.579 14.39 4.736 3.758 3.157 6.57 7.352 8.434 12.585 1.444-3.068 3.248-5.698 5.413-7.894 2.165-2.194 4.541-3.984 7.127-5.367a32.848 32.848 0 018.254-3.068 39.597 39.597 0 018.796-.992c5.111 0 9.653.783 13.622 2.345 3.97 1.565 7.307 3.849 10.014 6.857 2.706 3.007 4.766 6.675 6.18 11.005C739.29 83.537 740 88.5 740 94.092V153h-22.284V94.092c0-5.894-1.294-10.329-3.878-13.306-2.587-2.977-6.376-4.465-11.368-4.465-2.286 0-4.404.391-6.358 1.172a15.189 15.189 0 00-5.144 3.383c-1.473 1.474-2.631 3.324-3.474 5.548-.842 2.225-1.263 4.781-1.263 7.668V153h-22.37V94.092c0-6.194-1.249-10.704-3.744-13.532-2.497-2.825-6.18-4.24-11.051-4.24-3.19 0-6.18.798-8.976 2.391-2.799 1.593-5.399 3.775-7.804 6.54V153H610zM572 30h23v19h-23z"
+ fill="currentColor"
+ fill-opacity=".8"
+ />
+ </g>
+ </g>
+ </svg>
+ ]]
+
+ local main = ''
+ for _, tree in ipairs(lang_tree:trees()) do
+ main = main
+ .. (
+ visit_node(
+ tree:root(),
+ 0,
+ tree,
+ headings,
+ { buf = buf, old = old, fname = fname, to_fname = to_fname, indent = 1 },
+ stats
+ )
+ )
+ end
+
+ main = ([[
+ <header class="container">
+ <nav class="navbar navbar-expand-lg">
+ <div class="container-fluid">
+ <a href="/" class="navbar-brand" aria-label="logo">
+ <!--TODO: use <img src="….svg"> here instead. Need one that has green lettering instead of gray. -->
+ %s
+ <!--<img src="https://neovim.io/logos/neovim-logo.svg" width="173" height="50" alt="Neovim" />-->
+ </a>
+ <div id="docsearch"></div> <!-- algolia docsearch https://docsearch.algolia.com/docs/docsearch-v3/ -->
+ </div>
+ </nav>
+ </header>
+
+ <div class="container golden-grid help-body">
+ <div class="col-wide">
+ <a name="%s" href="#%s"><h1 id="%s">%s</h1></a>
+ <p>
+ <i>
+ Nvim <code>:help</code> pages, <a href="https://github.com/neovim/neovim/blob/master/scripts/gen_help_html.lua">generated</a>
+ from <a href="https://github.com/neovim/neovim/blob/master/runtime/doc/%s">source</a>
+ using the <a href="https://github.com/neovim/tree-sitter-vimdoc">tree-sitter-vimdoc</a> parser.
+ </i>
+ </p>
+ <hr/>
+ %s
+ </div>
+ ]]):format(
+ logo_svg,
+ stats.first_tags[1] or '',
+ stats.first_tags[2] or '',
+ stats.first_tags[2] or '',
+ title,
+ vim.fs.basename(fname),
+ main
+ )
+
+ ---@type string
+ local toc = [[
+ <div class="col-narrow toc">
+ <div><a href="index.html">Main</a></div>
+ <div><a href="vimindex.html">Commands index</a></div>
+ <div><a href="quickref.html">Quick reference</a></div>
+ <hr/>
+ ]]
+
+ local n = 0 -- Count of all headings + subheadings.
+ for _, h1 in ipairs(headings) do
+ n = n + 1 + #h1.subheadings
+ end
+ for _, h1 in ipairs(headings) do
+ ---@type string
+ toc = toc .. ('<div class="help-toc-h1"><a href="#%s">%s</a>\n'):format(h1.tag, h1.name)
+ if n < 30 or #headings < 10 then -- Show subheadings only if there aren't too many.
+ for _, h2 in ipairs(h1.subheadings) do
+ toc = toc
+ .. ('<div class="help-toc-h2"><a href="#%s">%s</a></div>\n'):format(h2.tag, h2.name)
+ end
+ end
+ toc = toc .. '</div>'
+ end
+ toc = toc .. '</div>\n'
+
+ local bug_url = get_bug_url_nvim(fname, to_fname, 'TODO', nil)
+ local bug_link = string.format('(<a href="%s" target="_blank">report docs bug...</a>)', bug_url)
+
+ local footer = ([[
+ <footer>
+ <div class="container flex">
+ <div class="generator-stats">
+ Generated at %s from <code><a href="https://github.com/neovim/neovim/commit/%s">%s</a></code>
+ </div>
+ <div class="generator-stats">
+ parse_errors: %d %s | <span title="%s">noise_lines: %d</span>
+ </div>
+ <div>
+
+ <!-- algolia docsearch https://docsearch.algolia.com/docs/docsearch-v3/ -->
+ <script src="https://cdn.jsdelivr.net/npm/@docsearch/js@3"></script>
+ <script type="module">
+ docsearch({
+ container: '#docsearch',
+ appId: 'X185E15FPG',
+ apiKey: 'b5e6b2f9c636b2b471303205e59832ed',
+ indexName: 'nvim',
+ });
+ </script>
+
+ </footer>
+ ]]):format(
+ os.date('%Y-%m-%d %H:%M'),
+ commit,
+ commit:sub(1, 7),
+ #stats.parse_errors,
+ bug_link,
+ html_esc(table.concat(stats.noise_lines, '\n')),
+ #stats.noise_lines
+ )
+
+ html = ('%s%s%s</div>\n%s</body>\n</html>\n'):format(html, main, toc, footer)
+ vim.cmd('q!')
+ lang_tree:destroy()
+ return html, stats
+end
+
+local function gen_css(fname)
+ local css = [[
+ :root {
+ --code-color: #004b4b;
+ --tag-color: #095943;
+ }
+ @media (prefers-color-scheme: dark) {
+ :root {
+ --code-color: #00c243;
+ --tag-color: #00b7b7;
+ }
+ }
+ @media (min-width: 40em) {
+ .toc {
+ position: fixed;
+ left: 67%;
+ }
+ .golden-grid {
+ display: grid;
+ grid-template-columns: 65% auto;
+ grid-gap: 1em;
+ }
+ }
+ @media (max-width: 40em) {
+ .golden-grid {
+ /* Disable grid for narrow viewport (mobile phone). */
+ display: block;
+ }
+ }
+ .toc {
+ /* max-width: 12rem; */
+ height: 85%; /* Scroll if there are too many items. https://github.com/neovim/neovim.github.io/issues/297 */
+ overflow: auto; /* Scroll if there are too many items. https://github.com/neovim/neovim.github.io/issues/297 */
+ }
+ .toc > div {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ }
+ html {
+ scroll-behavior: auto;
+ }
+ body {
+ font-size: 18px;
+ line-height: 1.5;
+ }
+ h1, h2, h3, h4, h5 {
+ font-family: sans-serif;
+ border-bottom: 1px solid var(--tag-color); /*rgba(0, 0, 0, .9);*/
+ }
+ h3, h4, h5 {
+ border-bottom-style: dashed;
+ }
+ .help-column_heading {
+ color: var(--code-color);
+ }
+ .help-body {
+ padding-bottom: 2em;
+ }
+ .help-line {
+ /* font-family: ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace; */
+ }
+ .help-li {
+ white-space: normal;
+ display: list-item;
+ margin-left: 1.5rem; /* padding-left: 1rem; */
+ }
+ .help-para {
+ padding-top: 10px;
+ padding-bottom: 10px;
+ }
+
+ .old-help-para {
+ padding-top: 10px;
+ padding-bottom: 10px;
+ /* Tabs are used for alignment in old docs, so we must match Vim's 8-char expectation. */
+ tab-size: 8;
+ white-space: pre-wrap;
+ font-size: 16px;
+ font-family: ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace;
+ word-wrap: break-word;
+ }
+ .old-help-para pre, .old-help-para pre:hover {
+ /* Text following <pre> is already visually separated by the linebreak. */
+ margin-bottom: 0;
+ /* Long lines that exceed the textwidth should not be wrapped (no "pre-wrap").
+ Since text may overflow horizontally, we make the contents to be scrollable
+ (only if necessary) to prevent overlapping with the navigation bar at the right. */
+ white-space: pre;
+ overflow-x: auto;
+ }
+
+ /* TODO: should this rule be deleted? help tags are rendered as <code> or <span>, not <a> */
+ a.help-tag, a.help-tag:focus, a.help-tag:hover {
+ color: inherit;
+ text-decoration: none;
+ }
+ .help-tag {
+ color: var(--tag-color);
+ }
+ /* Tag pseudo-header common in :help docs. */
+ .help-tag-right {
+ color: var(--tag-color);
+ margin-left: auto;
+ margin-right: 0;
+ float: right;
+ display: block;
+ }
+ .help-tag a,
+ .help-tag-right a {
+ color: inherit;
+ }
+ .help-tag a:not(:hover),
+ .help-tag-right a:not(:hover) {
+ text-decoration: none;
+ }
+ h1 .help-tag, h2 .help-tag, h3 .help-tag {
+ font-size: smaller;
+ }
+ .help-heading {
+ white-space: normal;
+ display: flex;
+ flex-flow: row wrap;
+ justify-content: space-between;
+ gap: 0 15px;
+ }
+ /* The (right-aligned) "tags" part of a section heading. */
+ .help-heading-tags {
+ margin-right: 10px;
+ }
+ .help-toc-h1 {
+ }
+ .help-toc-h2 {
+ margin-left: 1em;
+ }
+ .parse-error {
+ background-color: red;
+ }
+ .unknown-token {
+ color: black;
+ background-color: yellow;
+ }
+ code {
+ color: var(--code-color);
+ font-size: 16px;
+ }
+ pre {
+ /* Tabs are used in codeblocks only for indentation, not alignment, so we can aggressively shrink them. */
+ tab-size: 2;
+ white-space: pre-wrap;
+ line-height: 1.3; /* Important for ascii art. */
+ overflow: visible;
+ /* font-family: ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace; */
+ font-size: 16px;
+ margin-top: 10px;
+ }
+ pre:last-child {
+ margin-bottom: 0;
+ }
+ pre:hover {
+ overflow: visible;
+ }
+ .generator-stats {
+ color: gray;
+ font-size: smaller;
+ }
+ ]]
+ tofile(fname, css)
+end
+
+-- Testing
+
+local function ok(cond, expected, actual, message)
+ assert(
+ (not expected and not actual) or (expected and actual),
+ 'if "expected" is given, "actual" is also required'
+ )
+ if expected then
+ assert(
+ cond,
+ ('%sexpected %s, got: %s'):format(
+ message and (message .. '\n') or '',
+ vim.inspect(expected),
+ vim.inspect(actual)
+ )
+ )
+ return cond
+ else
+ return assert(cond)
+ end
+end
+local function eq(expected, actual, message)
+ return ok(vim.deep_equal(expected, actual), expected, actual, message)
+end
+
+function M._test()
+ tagmap = get_helptags('$VIMRUNTIME/doc')
+ helpfiles = get_helpfiles(vim.fs.normalize('$VIMRUNTIME/doc'))
+
+ ok(vim.tbl_count(tagmap) > 3000, '>3000', vim.tbl_count(tagmap))
+ ok(
+ vim.endswith(tagmap['vim.diagnostic.set()'], 'diagnostic.txt'),
+ tagmap['vim.diagnostic.set()'],
+ 'diagnostic.txt'
+ )
+ ok(vim.endswith(tagmap['%:s'], 'cmdline.txt'), tagmap['%:s'], 'cmdline.txt')
+ ok(is_noise([[vim:tw=78:isk=!-~,^*,^\|,^\":ts=8:noet:ft=help:norl:]]))
+ ok(is_noise([[ NVIM REFERENCE MANUAL by Thiago de Arruda ]]))
+ ok(not is_noise([[vim:tw=78]]))
+
+ eq(0, get_indent('a'))
+ eq(1, get_indent(' a'))
+ eq(2, get_indent(' a\n b\n c\n'))
+ eq(5, get_indent(' a\n \n b\n c\n d\n e\n'))
+ eq(
+ 'a\n \n b\n c\n d\n e\n',
+ trim_indent(' a\n \n b\n c\n d\n e\n')
+ )
+
+ local fixed_url, removed_chars = fix_url('https://example.com).')
+ eq('https://example.com', fixed_url)
+ eq(').', removed_chars)
+ fixed_url, removed_chars = fix_url('https://example.com.)')
+ eq('https://example.com.', fixed_url)
+ eq(')', removed_chars)
+ fixed_url, removed_chars = fix_url('https://example.com.')
+ eq('https://example.com', fixed_url)
+ eq('.', removed_chars)
+ fixed_url, removed_chars = fix_url('https://example.com)')
+ eq('https://example.com', fixed_url)
+ eq(')', removed_chars)
+ fixed_url, removed_chars = fix_url('https://example.com')
+ eq('https://example.com', fixed_url)
+ eq('', removed_chars)
+
+ print('all tests passed.\n')
+end
+
+--- @class nvim.gen_help_html.gen_result
+--- @field helpfiles string[] list of generated HTML files, from the source docs {include}
+--- @field err_count integer number of parse errors in :help docs
+--- @field invalid_links table<string, any>
+
+--- Generates HTML from :help docs located in `help_dir` and writes the result in `to_dir`.
+---
+--- Example:
+---
+--- gen('$VIMRUNTIME/doc', '/path/to/neovim.github.io/_site/doc/', {'api.txt', 'autocmd.txt', 'channel.txt'}, nil)
+---
+--- @param help_dir string Source directory containing the :help files. Must run `make helptags` first.
+--- @param to_dir string Target directory where the .html files will be written.
+--- @param include string[]|nil Process only these filenames. Example: {'api.txt', 'autocmd.txt', 'channel.txt'}
+---
+--- @return nvim.gen_help_html.gen_result result
+function M.gen(help_dir, to_dir, include, commit, parser_path)
+ vim.validate('help_dir', help_dir, function(d)
+ return vim.fn.isdirectory(vim.fs.normalize(d)) == 1
+ end, 'valid directory')
+ vim.validate('to_dir', to_dir, 'string')
+ vim.validate('include', include, 'table', true)
+ vim.validate('commit', commit, 'string', true)
+ vim.validate('parser_path', parser_path, function(f)
+ return vim.fn.filereadable(vim.fs.normalize(f)) == 1
+ end, true, 'valid vimdoc.{so,dll} filepath')
+
+ local err_count = 0
+ local redirects_count = 0
+ ensure_runtimepath()
+ tagmap = get_helptags(vim.fs.normalize(help_dir))
+ helpfiles = get_helpfiles(help_dir, include)
+ to_dir = vim.fs.normalize(to_dir)
+ parser_path = parser_path and vim.fs.normalize(parser_path) or nil
+
+ print(('output dir: %s\n\n'):format(to_dir))
+ vim.fn.mkdir(to_dir, 'p')
+ gen_css(('%s/help.css'):format(to_dir))
+
+ for _, f in ipairs(helpfiles) do
+ -- "foo.txt"
+ local helpfile = vim.fs.basename(f)
+ -- "to/dir/foo.html"
+ local to_fname = ('%s/%s'):format(to_dir, get_helppage(helpfile))
+ local html, stats =
+ gen_one(f, nil, to_fname, not new_layout[helpfile], commit or '?', parser_path)
+ tofile(to_fname, html)
+ print(
+ ('generated (%-2s errors): %-15s => %s'):format(
+ #stats.parse_errors,
+ helpfile,
+ vim.fs.basename(to_fname)
+ )
+ )
+
+ -- Generate redirect pages for renamed help files.
+ local helpfile_tag = (helpfile:gsub('%.txt$', ''))
+ local redirect_from = redirects[helpfile_tag]
+ if redirect_from then
+ local redirect_text = ([[
+*%s* Nvim
+
+This document moved to: |%s|
+
+==============================================================================
+This document moved to: |%s|
+
+This document moved to: |%s|
+
+==============================================================================
+ vim:tw=78:ts=8:ft=help:norl:
+ ]]):format(
+ redirect_from,
+ helpfile_tag,
+ helpfile_tag,
+ helpfile_tag,
+ helpfile_tag,
+ helpfile_tag
+ )
+ local redirect_to = ('%s/%s'):format(to_dir, get_helppage(redirect_from))
+ local redirect_html, _ =
+ gen_one(redirect_from, redirect_text, redirect_to, false, commit or '?', parser_path)
+ assert(redirect_html:find(helpfile_tag))
+ tofile(redirect_to, redirect_html)
+
+ print(
+ ('generated (redirect) : %-15s => %s'):format(
+ redirect_from .. '.txt',
+ vim.fs.basename(to_fname)
+ )
+ )
+ redirects_count = redirects_count + 1
+ end
+
+ err_count = err_count + #stats.parse_errors
+ end
+
+ print(('\ngenerated %d html pages'):format(#helpfiles + redirects_count))
+ print(('total errors: %d'):format(err_count))
+ print(('invalid tags: %s'):format(vim.inspect(invalid_links)))
+ assert(#(include or {}) > 0 or redirects_count == vim.tbl_count(redirects)) -- sanity check
+ print(('redirects: %d'):format(redirects_count))
+ print('\n')
+
+ --- @type nvim.gen_help_html.gen_result
+ return {
+ helpfiles = helpfiles,
+ err_count = err_count,
+ invalid_links = invalid_links,
+ }
+end
+
+--- @class nvim.gen_help_html.validate_result
+--- @field helpfiles integer number of generated helpfiles
+--- @field err_count integer number of parse errors
+--- @field parse_errors table<string, string[]>
+--- @field invalid_links table<string, any> invalid tags in :help docs
+--- @field invalid_urls table<string, any> invalid URLs in :help docs
+--- @field invalid_spelling table<string, table<string, string>> invalid spelling in :help docs
+
+--- Validates all :help files found in `help_dir`:
+--- - checks that |tag| links point to valid helptags.
+--- - recursively counts parse errors ("ERROR" nodes)
+---
+--- This is 10x faster than gen(), for use in CI.
+---
+--- @return nvim.gen_help_html.validate_result result
+function M.validate(help_dir, include, parser_path)
+ vim.validate('help_dir', help_dir, function(d)
+ return vim.fn.isdirectory(vim.fs.normalize(d)) == 1
+ end, 'valid directory')
+ vim.validate('include', include, 'table', true)
+ vim.validate('parser_path', parser_path, function(f)
+ return vim.fn.filereadable(vim.fs.normalize(f)) == 1
+ end, true, 'valid vimdoc.{so,dll} filepath')
+ local err_count = 0 ---@type integer
+ local files_to_errors = {} ---@type table<string, string[]>
+ ensure_runtimepath()
+ tagmap = get_helptags(vim.fs.normalize(help_dir))
+ helpfiles = get_helpfiles(help_dir, include)
+ parser_path = parser_path and vim.fs.normalize(parser_path) or nil
+
+ for _, f in ipairs(helpfiles) do
+ local helpfile = vim.fs.basename(f)
+ local rv = validate_one(f, parser_path)
+ print(('validated (%-4s errors): %s'):format(#rv.parse_errors, helpfile))
+ if #rv.parse_errors > 0 then
+ files_to_errors[helpfile] = rv.parse_errors
+ vim.print(('%s'):format(vim.iter(rv.parse_errors):fold('', function(s, v)
+ return s .. '\n ' .. v
+ end)))
+ end
+ err_count = err_count + #rv.parse_errors
+ end
+
+ ---@type nvim.gen_help_html.validate_result
+ return {
+ helpfiles = #helpfiles,
+ err_count = err_count,
+ parse_errors = files_to_errors,
+ invalid_links = invalid_links,
+ invalid_urls = invalid_urls,
+ invalid_spelling = invalid_spelling,
+ }
+end
+
+--- Validates vimdoc files on $VIMRUNTIME. and print human-readable error messages if fails.
+---
+--- If this fails, try these steps (in order):
+--- 1. Fix/cleanup the :help docs.
+--- 2. Fix the parser: https://github.com/neovim/tree-sitter-vimdoc
+--- 3. File a parser bug, and adjust the tolerance of this test in the meantime.
+---
+--- @param help_dir? string e.g. '$VIMRUNTIME/doc' or './runtime/doc'
+function M.run_validate(help_dir)
+ help_dir = vim.fs.normalize(help_dir or '$VIMRUNTIME/doc')
+ print('doc path = ' .. vim.uv.fs_realpath(help_dir))
+
+ local rv = M.validate(help_dir)
+
+ -- Check that we actually found helpfiles.
+ ok(rv.helpfiles > 100, '>100 :help files', rv.helpfiles)
+
+ eq({}, rv.parse_errors, 'no parse errors')
+ eq(0, rv.err_count, 'no parse errors')
+ eq({}, rv.invalid_links, 'invalid tags in :help docs')
+ eq({}, rv.invalid_urls, 'invalid URLs in :help docs')
+ eq(
+ {},
+ rv.invalid_spelling,
+ 'invalid spelling in :help docs (see spell_dict in scripts/gen_help_html.lua)'
+ )
+end
+
+--- Test-generates HTML from docs.
+---
+--- 1. Test that gen_help_html.lua actually works.
+--- 2. Test that parse errors did not increase wildly. Because we explicitly test only a few
+--- :help files, we can be precise about the tolerances here.
+--- @param help_dir? string e.g. '$VIMRUNTIME/doc' or './runtime/doc'
+function M.test_gen(help_dir)
+ local tmpdir = vim.fs.dirname(vim.fn.tempname())
+ help_dir = vim.fs.normalize(help_dir or '$VIMRUNTIME/doc')
+ print('doc path = ' .. vim.uv.fs_realpath(help_dir))
+
+ -- Because gen() is slow (~30s), this test is limited to a few files.
+ local input = { 'help.txt', 'index.txt', 'nvim.txt' }
+ local rv = M.gen(help_dir, tmpdir, input)
+ eq(#input, #rv.helpfiles)
+ eq(0, rv.err_count, 'parse errors in :help docs')
+ eq({}, rv.invalid_links, 'invalid tags in :help docs')
+end
+
+return M
diff --git a/src/gen/gen_lsp.lua b/src/gen/gen_lsp.lua
new file mode 100644
index 0000000000..38792307e4
--- /dev/null
+++ b/src/gen/gen_lsp.lua
@@ -0,0 +1,514 @@
+-- Generates lua-ls annotations for lsp.
+
+local USAGE = [[
+Generates lua-ls annotations for lsp.
+
+USAGE:
+nvim -l src/gen/gen_lsp.lua gen # by default, this will overwrite runtime/lua/vim/lsp/_meta/protocol.lua
+nvim -l src/gen/gen_lsp.lua gen --version 3.18 --out runtime/lua/vim/lsp/_meta/protocol.lua
+nvim -l src/gen/gen_lsp.lua gen --version 3.18 --methods --capabilities
+]]
+
+local DEFAULT_LSP_VERSION = '3.18'
+
+local M = {}
+
+local function tofile(fname, text)
+ local f = io.open(fname, 'w')
+ if not f then
+ error(('failed to write: %s'):format(f))
+ else
+ print(('Written to: %s'):format(fname))
+ f:write(text)
+ f:close()
+ end
+end
+
+--- The LSP protocol JSON data (it's partial, non-exhaustive).
+--- https://raw.githubusercontent.com/microsoft/language-server-protocol/gh-pages/_specifications/lsp/3.18/metaModel/metaModel.schema.json
+--- @class vim._gen_lsp.Protocol
+--- @field requests vim._gen_lsp.Request[]
+--- @field notifications vim._gen_lsp.Notification[]
+--- @field structures vim._gen_lsp.Structure[]
+--- @field enumerations vim._gen_lsp.Enumeration[]
+--- @field typeAliases vim._gen_lsp.TypeAlias[]
+
+---@param opt vim._gen_lsp.opt
+---@return vim._gen_lsp.Protocol
+local function read_json(opt)
+ local uri = 'https://raw.githubusercontent.com/microsoft/language-server-protocol/gh-pages/_specifications/lsp/'
+ .. opt.version
+ .. '/metaModel/metaModel.json'
+ print('Reading ' .. uri)
+
+ local res = vim.system({ 'curl', '--no-progress-meter', uri, '-o', '-' }):wait()
+ if res.code ~= 0 or (res.stdout or ''):len() < 999 then
+ print(('URL failed: %s'):format(uri))
+ vim.print(res)
+ error(res.stdout)
+ end
+ return vim.json.decode(res.stdout)
+end
+
+-- Gets the Lua symbol for a given fully-qualified LSP method name.
+local function to_luaname(s)
+ -- "$/" prefix is special: https://microsoft.github.io/language-server-protocol/specification/#dollarRequests
+ return s:gsub('^%$', 'dollar'):gsub('/', '_')
+end
+
+---@param protocol vim._gen_lsp.Protocol
+---@param gen_methods boolean
+---@param gen_capabilities boolean
+local function write_to_protocol(protocol, gen_methods, gen_capabilities)
+ if not gen_methods and not gen_capabilities then
+ return
+ end
+
+ local indent = (' '):rep(2)
+
+ --- @class vim._gen_lsp.Request
+ --- @field deprecated? string
+ --- @field documentation? string
+ --- @field messageDirection string
+ --- @field clientCapability? string
+ --- @field serverCapability? string
+ --- @field method string
+ --- @field params? any
+ --- @field proposed? boolean
+ --- @field registrationMethod? string
+ --- @field registrationOptions? any
+ --- @field since? string
+
+ --- @class vim._gen_lsp.Notification
+ --- @field deprecated? string
+ --- @field documentation? string
+ --- @field errorData? any
+ --- @field messageDirection string
+ --- @field clientCapability? string
+ --- @field serverCapability? string
+ --- @field method string
+ --- @field params? any[]
+ --- @field partialResult? any
+ --- @field proposed? boolean
+ --- @field registrationMethod? string
+ --- @field registrationOptions? any
+ --- @field result any
+ --- @field since? string
+
+ ---@type (vim._gen_lsp.Request|vim._gen_lsp.Notification)[]
+ local all = vim.list_extend(protocol.requests, protocol.notifications)
+ table.sort(all, function(a, b)
+ return to_luaname(a.method) < to_luaname(b.method)
+ end)
+
+ local output = { '-- Generated by gen_lsp.lua, keep at end of file.' }
+
+ if gen_methods then
+ output[#output + 1] = '--- @alias vim.lsp.protocol.Method.ClientToServer'
+
+ for _, item in ipairs(all) do
+ if item.method and item.messageDirection == 'clientToServer' then
+ output[#output + 1] = ("--- | '%s',"):format(item.method)
+ end
+ end
+
+ vim.list_extend(output, {
+ '',
+ '--- @alias vim.lsp.protocol.Method.ServerToClient',
+ })
+ for _, item in ipairs(all) do
+ if item.method and item.messageDirection == 'serverToClient' then
+ output[#output + 1] = ("--- | '%s',"):format(item.method)
+ end
+ end
+
+ vim.list_extend(output, {
+ '',
+ '--- @alias vim.lsp.protocol.Method',
+ '--- | vim.lsp.protocol.Method.ClientToServer',
+ '--- | vim.lsp.protocol.Method.ServerToClient',
+ '',
+ '-- Generated by gen_lsp.lua, keep at end of file.',
+ '--- @enum vim.lsp.protocol.Methods',
+ '--- @see https://microsoft.github.io/language-server-protocol/specification/#metaModel',
+ '--- LSP method names.',
+ 'protocol.Methods = {',
+ })
+
+ for _, item in ipairs(all) do
+ if item.method then
+ if item.documentation then
+ local document = vim.split(item.documentation, '\n?\n', { trimempty = true })
+ for _, docstring in ipairs(document) do
+ output[#output + 1] = indent .. '--- ' .. docstring
+ end
+ end
+ output[#output + 1] = ("%s%s = '%s',"):format(indent, to_luaname(item.method), item.method)
+ end
+ end
+ output[#output + 1] = '}'
+ end
+
+ if gen_capabilities then
+ vim.list_extend(output, {
+ '',
+ '-- stylua: ignore start',
+ '-- Generated by gen_lsp.lua, keep at end of file.',
+ '--- Maps method names to the required server capability',
+ 'protocol._request_name_to_capability = {',
+ })
+
+ for _, item in ipairs(all) do
+ if item.serverCapability then
+ output[#output + 1] = ("%s['%s'] = { %s },"):format(
+ indent,
+ item.method,
+ table.concat(
+ vim
+ .iter(vim.split(item.serverCapability, '.', { plain = true }))
+ :map(function(segment)
+ return "'" .. segment .. "'"
+ end)
+ :totable(),
+ ', '
+ )
+ )
+ end
+ end
+
+ output[#output + 1] = '}'
+ output[#output + 1] = '-- stylua: ignore end'
+ end
+
+ output[#output + 1] = ''
+ output[#output + 1] = 'return protocol'
+
+ local fname = './runtime/lua/vim/lsp/protocol.lua'
+ local bufnr = vim.fn.bufadd(fname)
+ vim.fn.bufload(bufnr)
+ vim.api.nvim_set_current_buf(bufnr)
+ local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
+ local index = vim.iter(ipairs(lines)):find(function(key, item)
+ return vim.startswith(item, '-- Generated by') and key or nil
+ end)
+ index = index and index - 1 or vim.api.nvim_buf_line_count(bufnr) - 1
+ vim.api.nvim_buf_set_lines(bufnr, index, -1, true, output)
+ vim.cmd.write()
+end
+
+---@class vim._gen_lsp.opt
+---@field output_file string
+---@field version string
+---@field methods boolean
+---@field capabilities boolean
+
+---@param opt vim._gen_lsp.opt
+function M.gen(opt)
+ --- @type vim._gen_lsp.Protocol
+ local protocol = read_json(opt)
+
+ write_to_protocol(protocol, opt.methods, opt.capabilities)
+
+ local output = {
+ '--' .. '[[',
+ 'THIS FILE IS GENERATED by scr/gen/gen_lsp.lua',
+ 'DO NOT EDIT MANUALLY',
+ '',
+ 'Based on LSP protocol ' .. opt.version,
+ '',
+ 'Regenerate:',
+ ([=[nvim -l scr/gen/gen_lsp.lua gen --version %s]=]):format(DEFAULT_LSP_VERSION),
+ '--' .. ']]',
+ '',
+ '---@meta',
+ "error('Cannot require a meta file')",
+ '',
+ '---@alias lsp.null nil',
+ '---@alias uinteger integer',
+ '---@alias decimal number',
+ '---@alias lsp.DocumentUri string',
+ '---@alias lsp.URI string',
+ '',
+ }
+
+ local anonymous_num = 0
+
+ ---@type string[]
+ local anonym_classes = {}
+
+ local simple_types = {
+ 'string',
+ 'boolean',
+ 'integer',
+ 'uinteger',
+ 'decimal',
+ }
+
+ ---@param documentation string
+ local _process_documentation = function(documentation)
+ documentation = documentation:gsub('\n', '\n---')
+ -- Remove <200b> (zero-width space) unicode characters: e.g., `**/<200b>*`
+ documentation = documentation:gsub('\226\128\139', '')
+ -- Escape annotations that are not recognized by lua-ls
+ documentation = documentation:gsub('%^---@sample', '---\\@sample')
+ return '---' .. documentation
+ end
+
+ --- @class vim._gen_lsp.Type
+ --- @field kind string a common field for all Types.
+ --- @field name? string for ReferenceType, BaseType
+ --- @field element? any for ArrayType
+ --- @field items? vim._gen_lsp.Type[] for OrType, AndType
+ --- @field key? vim._gen_lsp.Type for MapType
+ --- @field value? string|vim._gen_lsp.Type for StringLiteralType, MapType, StructureLiteralType
+
+ ---@param type vim._gen_lsp.Type
+ ---@param prefix? string Optional prefix associated with the this type, made of (nested) field name.
+ --- Used to generate class name for structure literal types.
+ ---@return string
+ local function parse_type(type, prefix)
+ -- ReferenceType | BaseType
+ if type.kind == 'reference' or type.kind == 'base' then
+ if vim.tbl_contains(simple_types, type.name) then
+ return type.name
+ end
+ return 'lsp.' .. type.name
+
+ -- ArrayType
+ elseif type.kind == 'array' then
+ local parsed_items = parse_type(type.element, prefix)
+ if type.element.items and #type.element.items > 1 then
+ parsed_items = '(' .. parsed_items .. ')'
+ end
+ return parsed_items .. '[]'
+
+ -- OrType
+ elseif type.kind == 'or' then
+ local val = ''
+ for _, item in ipairs(type.items) do
+ val = val .. parse_type(item, prefix) .. '|' --[[ @as string ]]
+ end
+ val = val:sub(0, -2)
+ return val
+
+ -- StringLiteralType
+ elseif type.kind == 'stringLiteral' then
+ return '"' .. type.value .. '"'
+
+ -- MapType
+ elseif type.kind == 'map' then
+ local key = assert(type.key)
+ local value = type.value --[[ @as vim._gen_lsp.Type ]]
+ return 'table<' .. parse_type(key, prefix) .. ', ' .. parse_type(value, prefix) .. '>'
+
+ -- StructureLiteralType
+ elseif type.kind == 'literal' then
+ -- can I use ---@param disabled? {reason: string}
+ -- use | to continue the inline class to be able to add docs
+ -- https://github.com/LuaLS/lua-language-server/issues/2128
+ anonymous_num = anonymous_num + 1
+ local anonymous_classname = 'lsp._anonym' .. anonymous_num
+ if prefix then
+ anonymous_classname = anonymous_classname .. '.' .. prefix
+ end
+ local anonym = vim
+ .iter({
+ (anonymous_num > 1 and { '' } or {}),
+ { '---@class ' .. anonymous_classname },
+ })
+ :flatten()
+ :totable()
+
+ --- @class vim._gen_lsp.StructureLiteral translated to anonymous @class.
+ --- @field deprecated? string
+ --- @field description? string
+ --- @field properties vim._gen_lsp.Property[]
+ --- @field proposed? boolean
+ --- @field since? string
+
+ ---@type vim._gen_lsp.StructureLiteral
+ local structural_literal = assert(type.value) --[[ @as vim._gen_lsp.StructureLiteral ]]
+ for _, field in ipairs(structural_literal.properties) do
+ anonym[#anonym + 1] = '---'
+ if field.documentation then
+ anonym[#anonym + 1] = _process_documentation(field.documentation)
+ end
+ anonym[#anonym + 1] = '---@field '
+ .. field.name
+ .. (field.optional and '?' or '')
+ .. ' '
+ .. parse_type(field.type, prefix .. '.' .. field.name)
+ end
+ -- anonym[#anonym + 1] = ''
+ for _, line in ipairs(anonym) do
+ if line then
+ anonym_classes[#anonym_classes + 1] = line
+ end
+ end
+ return anonymous_classname
+
+ -- TupleType
+ elseif type.kind == 'tuple' then
+ local tuple = '['
+ for _, value in ipairs(type.items) do
+ tuple = tuple .. parse_type(value, prefix) .. ', '
+ end
+ -- remove , at the end
+ tuple = tuple:sub(0, -3)
+ return tuple .. ']'
+ end
+
+ vim.print('WARNING: Unknown type ', type)
+ return ''
+ end
+
+ --- @class vim._gen_lsp.Structure translated to @class
+ --- @field deprecated? string
+ --- @field documentation? string
+ --- @field extends? { kind: string, name: string }[]
+ --- @field mixins? { kind: string, name: string }[]
+ --- @field name string
+ --- @field properties? vim._gen_lsp.Property[] members, translated to @field
+ --- @field proposed? boolean
+ --- @field since? string
+ for _, structure in ipairs(protocol.structures) do
+ -- output[#output + 1] = ''
+ if structure.documentation then
+ output[#output + 1] = _process_documentation(structure.documentation)
+ end
+ local class_string = ('---@class lsp.%s'):format(structure.name)
+ if structure.extends or structure.mixins then
+ local inherits_from = table.concat(
+ vim.list_extend(
+ vim.tbl_map(parse_type, structure.extends or {}),
+ vim.tbl_map(parse_type, structure.mixins or {})
+ ),
+ ', '
+ )
+ class_string = class_string .. ': ' .. inherits_from
+ end
+ output[#output + 1] = class_string
+
+ --- @class vim._gen_lsp.Property translated to @field
+ --- @field deprecated? string
+ --- @field documentation? string
+ --- @field name string
+ --- @field optional? boolean
+ --- @field proposed? boolean
+ --- @field since? string
+ --- @field type { kind: string, name: string }
+ for _, field in ipairs(structure.properties or {}) do
+ output[#output + 1] = '---' -- Insert a single newline between @fields (and after @class)
+ if field.documentation then
+ output[#output + 1] = _process_documentation(field.documentation)
+ end
+ output[#output + 1] = '---@field '
+ .. field.name
+ .. (field.optional and '?' or '')
+ .. ' '
+ .. parse_type(field.type, field.name)
+ end
+ output[#output + 1] = ''
+ end
+
+ --- @class vim._gen_lsp.Enumeration translated to @enum
+ --- @field deprecated string?
+ --- @field documentation string?
+ --- @field name string?
+ --- @field proposed boolean?
+ --- @field since string?
+ --- @field suportsCustomValues boolean?
+ --- @field values { name: string, value: string, documentation?: string, since?: string }[]
+ for _, enum in ipairs(protocol.enumerations) do
+ if enum.documentation then
+ output[#output + 1] = _process_documentation(enum.documentation)
+ end
+ local enum_type = '---@alias lsp.' .. enum.name
+ for _, value in ipairs(enum.values) do
+ enum_type = enum_type
+ .. '\n---| '
+ .. (type(value.value) == 'string' and '"' .. value.value .. '"' or value.value)
+ .. ' # '
+ .. value.name
+ end
+ output[#output + 1] = enum_type
+ output[#output + 1] = ''
+ end
+
+ --- @class vim._gen_lsp.TypeAlias translated to @alias
+ --- @field deprecated? string?
+ --- @field documentation? string
+ --- @field name string
+ --- @field proposed? boolean
+ --- @field since? string
+ --- @field type vim._gen_lsp.Type
+ for _, alias in ipairs(protocol.typeAliases) do
+ if alias.documentation then
+ output[#output + 1] = _process_documentation(alias.documentation)
+ end
+ if alias.type.kind == 'or' then
+ local alias_type = '---@alias lsp.' .. alias.name .. ' '
+ for _, item in ipairs(alias.type.items) do
+ alias_type = alias_type .. parse_type(item, alias.name) .. '|'
+ end
+ alias_type = alias_type:sub(0, -2)
+ output[#output + 1] = alias_type
+ else
+ output[#output + 1] = '---@alias lsp.'
+ .. alias.name
+ .. ' '
+ .. parse_type(alias.type, alias.name)
+ end
+ output[#output + 1] = ''
+ end
+
+ -- anonymous classes
+ for _, line in ipairs(anonym_classes) do
+ output[#output + 1] = line
+ end
+
+ tofile(opt.output_file, table.concat(output, '\n') .. '\n')
+end
+
+---@type vim._gen_lsp.opt
+local opt = {
+ output_file = 'runtime/lua/vim/lsp/_meta/protocol.lua',
+ version = DEFAULT_LSP_VERSION,
+ methods = false,
+ capabilities = false,
+}
+
+local command = nil
+local i = 1
+while i <= #_G.arg do
+ if _G.arg[i] == '--out' then
+ opt.output_file = assert(_G.arg[i + 1], '--out <outfile> needed')
+ i = i + 1
+ elseif _G.arg[i] == '--version' then
+ opt.version = assert(_G.arg[i + 1], '--version <version> needed')
+ i = i + 1
+ elseif _G.arg[i] == '--methods' then
+ opt.methods = true
+ elseif _G.arg[i] == '--capabilities' then
+ opt.capabilities = true
+ elseif vim.startswith(_G.arg[i], '-') then
+ error('Unrecognized args: ' .. _G.arg[i])
+ else
+ if command then
+ error('More than one command was given: ' .. _G.arg[i])
+ else
+ command = _G.arg[i]
+ end
+ end
+ i = i + 1
+end
+
+if not command then
+ print(USAGE)
+elseif M[command] then
+ M[command](opt) -- see M.gen()
+else
+ error('Unknown command: ' .. command)
+end
+
+return M
diff --git a/src/gen/gen_options.lua b/src/gen/gen_options.lua
new file mode 100644
index 0000000000..1947297a0e
--- /dev/null
+++ b/src/gen/gen_options.lua
@@ -0,0 +1,535 @@
+--- @module 'nvim.options'
+local options = require('nvim.options')
+local options_meta = options.options
+local cstr = options.cstr
+local valid_scopes = options.valid_scopes
+
+--- @param o vim.option_meta
+--- @return string
+local function get_values_var(o)
+ return ('opt_%s_values'):format(o.abbreviation or o.full_name)
+end
+
+--- @param s string
+--- @return string
+local function lowercase_to_titlecase(s)
+ return table.concat(vim.tbl_map(function(word) --- @param word string
+ return word:sub(1, 1):upper() .. word:sub(2)
+ end, vim.split(s, '[-_]')))
+end
+
+--- @param scope string
+--- @param option_name string
+--- @return string
+local function get_scope_option(scope, option_name)
+ return ('k%sOpt%s'):format(lowercase_to_titlecase(scope), lowercase_to_titlecase(option_name))
+end
+
+local redraw_flags = {
+ ui_option = 'kOptFlagUIOption',
+ tabline = 'kOptFlagRedrTabl',
+ statuslines = 'kOptFlagRedrStat',
+ current_window = 'kOptFlagRedrWin',
+ current_buffer = 'kOptFlagRedrBuf',
+ all_windows = 'kOptFlagRedrAll',
+ curswant = 'kOptFlagCurswant',
+ highlight_only = 'kOptFlagHLOnly',
+}
+
+local list_flags = {
+ comma = 'kOptFlagComma',
+ onecomma = 'kOptFlagOneComma',
+ commacolon = 'kOptFlagComma|kOptFlagColon',
+ onecommacolon = 'kOptFlagOneComma|kOptFlagColon',
+ flags = 'kOptFlagFlagList',
+ flagscomma = 'kOptFlagComma|kOptFlagFlagList',
+}
+
+--- @param o vim.option_meta
+--- @return string
+local function get_flags(o)
+ --- @type string[]
+ local flags = { '0' }
+
+ --- @param f string
+ local function add_flag(f)
+ table.insert(flags, f)
+ end
+
+ if o.list then
+ add_flag(list_flags[o.list])
+ end
+
+ for _, r_flag in ipairs(o.redraw or {}) do
+ add_flag(redraw_flags[r_flag])
+ end
+
+ if o.expand then
+ add_flag('kOptFlagExpand')
+ if o.expand == 'nodefault' then
+ add_flag('kOptFlagNoDefExp')
+ end
+ end
+
+ for _, flag_desc in ipairs({
+ { 'nodefault', 'NoDefault' },
+ { 'no_mkrc', 'NoMkrc' },
+ { 'secure' },
+ { 'gettext' },
+ { 'noglob', 'NoGlob' },
+ { 'normal_fname_chars', 'NFname' },
+ { 'normal_dname_chars', 'NDname' },
+ { 'pri_mkrc', 'PriMkrc' },
+ { 'deny_in_modelines', 'NoML' },
+ { 'deny_duplicates', 'NoDup' },
+ { 'modelineexpr', 'MLE' },
+ { 'func' },
+ }) do
+ local key_name, flag_suffix = flag_desc[1], flag_desc[2]
+ if o[key_name] then
+ local def_name = 'kOptFlag' .. (flag_suffix or lowercase_to_titlecase(key_name))
+ add_flag(def_name)
+ end
+ end
+
+ return table.concat(flags, '|')
+end
+
+--- @param opt_type vim.option_type
+--- @return string
+local function opt_type_enum(opt_type)
+ return ('kOptValType%s'):format(lowercase_to_titlecase(opt_type))
+end
+
+--- @param scope vim.option_scope
+--- @return string
+local function opt_scope_enum(scope)
+ return ('kOptScope%s'):format(lowercase_to_titlecase(scope))
+end
+
+--- @param o vim.option_meta
+--- @return string
+local function get_scope_flags(o)
+ local scope_flags = '0'
+
+ for _, scope in ipairs(o.scope) do
+ scope_flags = ('%s | (1 << %s)'):format(scope_flags, opt_scope_enum(scope))
+ end
+
+ return scope_flags
+end
+
+--- @param o vim.option_meta
+--- @return string
+local function get_scope_idx(o)
+ --- @type string[]
+ local strs = {}
+
+ for _, scope in pairs(valid_scopes) do
+ local has_scope = vim.tbl_contains(o.scope, scope)
+ strs[#strs + 1] = (' [%s] = %s'):format(
+ opt_scope_enum(scope),
+ get_scope_option(scope, has_scope and o.full_name or 'Invalid')
+ )
+ end
+
+ return ('{\n%s\n }'):format(table.concat(strs, ',\n'))
+end
+
+--- @param s string
+--- @return string
+local function static_cstr_as_string(s)
+ return ('{ .data = %s, .size = sizeof(%s) - 1 }'):format(s, s)
+end
+
+--- @param v vim.option_value|function
+--- @return string
+local function get_opt_val(v)
+ --- @type vim.option_type
+ local v_type
+
+ if type(v) == 'function' then
+ v, v_type = v() --[[ @as string, vim.option_type ]]
+
+ if v_type == 'string' then
+ v = static_cstr_as_string(v)
+ end
+ else
+ v_type = type(v) --[[ @as vim.option_type ]]
+
+ if v_type == 'boolean' then
+ v = v and 'true' or 'false'
+ elseif v_type == 'number' then
+ v = ('%iL'):format(v)
+ elseif v_type == 'string' then
+ --- @cast v string
+ v = static_cstr_as_string(cstr(v))
+ end
+ end
+
+ return ('{ .type = %s, .data.%s = %s }'):format(opt_type_enum(v_type), v_type, v)
+end
+
+--- @param d vim.option_value|function
+--- @param n string
+--- @return string
+local function get_defaults(d, n)
+ if d == nil then
+ error("option '" .. n .. "' should have a default value")
+ end
+ return get_opt_val(d)
+end
+
+--- @param i integer
+--- @param o vim.option_meta
+--- @param write fun(...: string)
+local function dump_option(i, o, write)
+ write(' [', ('%u'):format(i - 1) .. ']={')
+ write(' .fullname=', cstr(o.full_name))
+ if o.abbreviation then
+ write(' .shortname=', cstr(o.abbreviation))
+ end
+ write(' .type=', opt_type_enum(o.type))
+ write(' .flags=', get_flags(o))
+ write(' .scope_flags=', get_scope_flags(o))
+ write(' .scope_idx=', get_scope_idx(o))
+ write(' .values=', (o.values and get_values_var(o) or 'NULL'))
+ write(' .values_len=', (o.values and #o.values or '0'))
+ write(' .flags_var=', (o.flags_varname and ('&%s'):format(o.flags_varname) or 'NULL'))
+ if o.enable_if then
+ write(('#if defined(%s)'):format(o.enable_if))
+ end
+
+ local is_window_local = #o.scope == 1 and o.scope[1] == 'win'
+
+ if is_window_local then
+ write(' .var=NULL')
+ elseif o.varname then
+ write(' .var=&', o.varname)
+ elseif o.immutable then
+ -- Immutable options can directly point to the default value.
+ write((' .var=&options[%u].def_val.data'):format(i - 1))
+ else
+ error('Option must be immutable or have a variable.')
+ end
+
+ write(' .immutable=', (o.immutable and 'true' or 'false'))
+ write(' .opt_did_set_cb=', o.cb or 'NULL')
+ write(' .opt_expand_cb=', o.expand_cb or 'NULL')
+
+ if o.enable_if then
+ write('#else')
+ -- Hidden option directly points to default value.
+ write((' .var=&options[%u].def_val.data'):format(i - 1))
+ -- Option is always immutable on the false branch of `enable_if`.
+ write(' .immutable=true')
+ write('#endif')
+ end
+
+ if not o.defaults then
+ write(' .def_val=NIL_OPTVAL')
+ elseif o.defaults.condition then
+ write(('#if defined(%s)'):format(o.defaults.condition))
+ write(' .def_val=', get_defaults(o.defaults.if_true, o.full_name))
+ if o.defaults.if_false then
+ write('#else')
+ write(' .def_val=', get_defaults(o.defaults.if_false, o.full_name))
+ end
+ write('#endif')
+ else
+ write(' .def_val=', get_defaults(o.defaults.if_true, o.full_name))
+ end
+
+ write(' },')
+end
+
+--- @param prefix string
+--- @param values vim.option_valid_values
+local function preorder_traversal(prefix, values)
+ local out = {} --- @type string[]
+
+ local function add(s)
+ table.insert(out, s)
+ end
+
+ add('')
+ add(('EXTERN const char *(%s_values[%s]) INIT( = {'):format(prefix, #vim.tbl_keys(values) + 1))
+
+ --- @type [string,vim.option_valid_values][]
+ local children = {}
+
+ for _, value in ipairs(values) do
+ if type(value) == 'string' then
+ add((' "%s",'):format(value))
+ else
+ assert(type(value) == 'table' and type(value[1]) == 'string' and type(value[2]) == 'table')
+ add((' "%s",'):format(value[1]))
+ table.insert(children, value)
+ end
+ end
+
+ add(' NULL')
+ add('});')
+
+ for _, value in pairs(children) do
+ -- Remove trailing colon from the added prefix to prevent syntax errors.
+ add(preorder_traversal(prefix .. '_' .. value[1]:gsub(':$', ''), value[2]))
+ end
+
+ return table.concat(out, '\n')
+end
+
+--- @param o vim.option_meta
+--- @return string
+local function gen_opt_enum(o)
+ local out = {} --- @type string[]
+
+ local function add(s)
+ table.insert(out, s)
+ end
+
+ add('')
+ add('typedef enum {')
+
+ local opt_name = lowercase_to_titlecase(o.abbreviation or o.full_name)
+ --- @type table<string,integer>
+ local enum_values
+
+ if type(o.flags) == 'table' then
+ enum_values = o.flags --[[ @as table<string,integer> ]]
+ else
+ enum_values = {}
+ for i, flag_name in ipairs(o.values) do
+ assert(type(flag_name) == 'string')
+ enum_values[flag_name] = math.pow(2, i - 1)
+ end
+ end
+
+ -- Sort the keys by the flag value so that the enum can be generated in order.
+ --- @type string[]
+ local flag_names = vim.tbl_keys(enum_values)
+ table.sort(flag_names, function(a, b)
+ return enum_values[a] < enum_values[b]
+ end)
+
+ for _, flag_name in pairs(flag_names) do
+ add(
+ (' kOpt%sFlag%s = 0x%02x,'):format(
+ opt_name,
+ lowercase_to_titlecase(flag_name:gsub(':$', '')),
+ enum_values[flag_name]
+ )
+ )
+ end
+
+ add(('} Opt%sFlags;'):format(opt_name))
+
+ return table.concat(out, '\n')
+end
+
+--- @param output_file string
+--- @return table<string,string> options_index Map of option name to option index
+local function gen_enums(output_file)
+ --- Options for each scope.
+ --- @type table<string, vim.option_meta[]>
+ local scope_options = {}
+ for _, scope in ipairs(valid_scopes) do
+ scope_options[scope] = {}
+ end
+
+ local fd = assert(io.open(output_file, 'w'))
+
+ --- @param s string
+ local function write(s)
+ fd:write(s)
+ fd:write('\n')
+ end
+
+ -- Generate options enum file
+ write('// IWYU pragma: private, include "nvim/option_defs.h"')
+ write('')
+
+ --- Map of option name to option index
+ --- @type table<string, string>
+ local option_index = {}
+
+ -- Generate option index enum and populate the `option_index` and `scope_option` dicts.
+ write('typedef enum {')
+ write(' kOptInvalid = -1,')
+
+ for i, o in ipairs(options_meta) do
+ local enum_val_name = 'kOpt' .. lowercase_to_titlecase(o.full_name)
+ write((' %s = %u,'):format(enum_val_name, i - 1))
+
+ option_index[o.full_name] = enum_val_name
+
+ if o.abbreviation then
+ option_index[o.abbreviation] = enum_val_name
+ end
+
+ local alias = o.alias or {} --[[@as string[] ]]
+ for _, v in ipairs(alias) do
+ option_index[v] = enum_val_name
+ end
+
+ for _, scope in ipairs(o.scope) do
+ table.insert(scope_options[scope], o)
+ end
+ end
+
+ write(' // Option count')
+ write('#define kOptCount ' .. tostring(#options_meta))
+ write('} OptIndex;')
+
+ -- Generate option index enum for each scope
+ for _, scope in ipairs(valid_scopes) do
+ write('')
+
+ local scope_name = lowercase_to_titlecase(scope)
+ write('typedef enum {')
+ write((' %s = -1,'):format(get_scope_option(scope, 'Invalid')))
+
+ for idx, option in ipairs(scope_options[scope]) do
+ write((' %s = %u,'):format(get_scope_option(scope, option.full_name), idx - 1))
+ end
+
+ write((' // %s option count'):format(scope_name))
+ write(('#define %s %d'):format(get_scope_option(scope, 'Count'), #scope_options[scope]))
+ write(('} %sOptIndex;'):format(scope_name))
+ end
+
+ -- Generate reverse lookup from option scope index to option index for each scope.
+ for _, scope in ipairs(valid_scopes) do
+ write('')
+ write(('EXTERN const OptIndex %s_opt_idx[] INIT( = {'):format(scope))
+ for _, option in ipairs(scope_options[scope]) do
+ local idx = option_index[option.full_name]
+ write((' [%s] = %s,'):format(get_scope_option(scope, option.full_name), idx))
+ end
+ write('});')
+ end
+
+ fd:close()
+
+ return option_index
+end
+
+--- @param output_file string
+--- @param option_index table<string,string>
+local function gen_map(output_file, option_index)
+ -- Generate option index map.
+ local hashy = require('gen.hashy')
+
+ local neworder, hashfun = hashy.hashy_hash(
+ 'find_option',
+ vim.tbl_keys(option_index),
+ function(idx)
+ return ('option_hash_elems[%s].name'):format(idx)
+ end
+ )
+
+ local fd = assert(io.open(output_file, 'w'))
+
+ --- @param s string
+ local function write(s)
+ fd:write(s)
+ fd:write('\n')
+ end
+
+ write('static const struct { const char *name; OptIndex opt_idx; } option_hash_elems[] = {')
+
+ for _, name in ipairs(neworder) do
+ assert(option_index[name] ~= nil)
+ write((' { .name = "%s", .opt_idx = %s },'):format(name, option_index[name]))
+ end
+
+ write('};')
+ write('')
+ write('static ' .. hashfun)
+
+ fd:close()
+end
+
+--- @param output_file string
+local function gen_vars(output_file)
+ local fd = assert(io.open(output_file, 'w'))
+
+ --- @param s string
+ local function write(s)
+ fd:write(s)
+ fd:write('\n')
+ end
+
+ write('// IWYU pragma: private, include "nvim/option_vars.h"')
+
+ -- Generate enums for option flags.
+ for _, o in ipairs(options_meta) do
+ if o.flags and (type(o.flags) == 'table' or o.values) then
+ write(gen_opt_enum(o))
+ end
+ end
+
+ -- Generate valid values for each option.
+ for _, option in ipairs(options_meta) do
+ -- Since option values can be nested, we need to do preorder traversal to generate the values.
+ if option.values then
+ local values_var = ('opt_%s'):format(option.abbreviation or option.full_name)
+ write(preorder_traversal(values_var, option.values))
+ end
+ end
+
+ fd:close()
+end
+
+--- @param output_file string
+local function gen_options(output_file)
+ local fd = assert(io.open(output_file, 'w'))
+
+ --- @param ... string
+ local function write(...)
+ local s = table.concat({ ... }, '')
+ fd:write(s)
+ if s:match('^ %.') then
+ fd:write(',')
+ end
+ fd:write('\n')
+ end
+
+ -- Generate options[] array.
+ write([[
+ #include "nvim/ex_docmd.h"
+ #include "nvim/ex_getln.h"
+ #include "nvim/insexpand.h"
+ #include "nvim/mapping.h"
+ #include "nvim/ops.h"
+ #include "nvim/option.h"
+ #include "nvim/optionstr.h"
+ #include "nvim/quickfix.h"
+ #include "nvim/runtime.h"
+ #include "nvim/tag.h"
+ #include "nvim/window.h"
+
+ static vimoption_T options[] = {]])
+
+ for i, o in ipairs(options_meta) do
+ dump_option(i, o, write)
+ end
+
+ write('};')
+
+ fd:close()
+end
+
+local function main()
+ local options_file = arg[1]
+ local options_enum_file = arg[2]
+ local options_map_file = arg[3]
+ local option_vars_file = arg[4]
+
+ local option_index = gen_enums(options_enum_file)
+ gen_map(options_map_file, option_index)
+ gen_vars(option_vars_file)
+ gen_options(options_file)
+end
+
+main()
diff --git a/src/gen/gen_vimdoc.lua b/src/gen/gen_vimdoc.lua
new file mode 100755
index 0000000000..2fe7224ea5
--- /dev/null
+++ b/src/gen/gen_vimdoc.lua
@@ -0,0 +1,1041 @@
+#!/usr/bin/env -S nvim -l
+--- Generates Nvim :help docs from Lua/C docstrings
+---
+--- The generated :help text for each function is formatted as follows:
+--- - Max width of 78 columns (`TEXT_WIDTH`).
+--- - Indent with spaces (not tabs).
+--- - Indent of 4 columns for body text (`INDENTATION`).
+--- - Function signature and helptag (right-aligned) on the same line.
+--- - Signature and helptag must have a minimum of 8 spaces between them.
+--- - If the signature is too long, it is placed on the line after the helptag.
+--- Signature wraps with subsequent lines indented to the open parenthesis.
+--- - Subsection bodies are indented an additional 4 spaces.
+--- - Body consists of function description, parameters, return description, and
+--- C declaration (`INCLUDE_C_DECL`).
+--- - Parameters are omitted for the `void` and `Error *` types, or if the
+--- parameter is marked as [out].
+--- - Each function documentation is separated by a single line.
+
+local luacats_parser = require('gen.luacats_parser')
+local cdoc_parser = require('gen.cdoc_parser')
+local util = require('gen.util')
+
+local fmt = string.format
+
+local wrap = util.wrap
+local md_to_vimdoc = util.md_to_vimdoc
+
+local TEXT_WIDTH = 78
+local INDENTATION = 4
+
+--- @class (exact) nvim.gen_vimdoc.Config
+---
+--- Generated documentation target, e.g. api.txt
+--- @field filename string
+---
+--- @field section_order string[]
+---
+--- List of files/directories for doxygen to read, relative to `base_dir`.
+--- @field files string[]
+---
+--- @field exclude_types? true
+---
+--- Section name overrides. Key: filename (e.g., vim.c)
+--- @field section_name? table<string,string>
+---
+--- @field fn_name_pat? string
+---
+--- @field fn_xform? fun(fun: nvim.luacats.parser.fun)
+---
+--- For generated section names.
+--- @field section_fmt fun(name: string): string
+---
+--- @field helptag_fmt fun(name: string): string|string[]
+---
+--- Per-function helptag.
+--- @field fn_helptag_fmt? fun(fun: nvim.luacats.parser.fun): string
+---
+--- @field append_only? string[]
+
+local function contains(t, xs)
+ return vim.tbl_contains(xs, t)
+end
+
+--- @type {level:integer, prerelease:boolean}?
+local nvim_api_info_
+
+--- @return {level: integer, prerelease:boolean}
+local function nvim_api_info()
+ if not nvim_api_info_ then
+ --- @type integer?, boolean?
+ local level, prerelease
+ for l in io.lines('CMakeLists.txt') do
+ --- @cast l string
+ if level and prerelease then
+ break
+ end
+ local m1 = l:match('^set%(NVIM_API_LEVEL%s+(%d+)%)')
+ if m1 then
+ level = tonumber(m1) --[[@as integer]]
+ end
+ local m2 = l:match('^set%(NVIM_API_PRERELEASE%s+(%w+)%)')
+ if m2 then
+ prerelease = m2 == 'true'
+ end
+ end
+ nvim_api_info_ = { level = level, prerelease = prerelease }
+ end
+
+ return nvim_api_info_
+end
+
+--- @param fun nvim.luacats.parser.fun
+--- @return string
+local function fn_helptag_fmt_common(fun)
+ local fn_sfx = fun.table and '' or '()'
+ if fun.classvar then
+ return fmt('%s:%s%s', fun.classvar, fun.name, fn_sfx)
+ end
+ if fun.module then
+ return fmt('%s.%s%s', fun.module, fun.name, fn_sfx)
+ end
+ return fun.name .. fn_sfx
+end
+
+--- @type table<string,nvim.gen_vimdoc.Config>
+local config = {
+ api = {
+ filename = 'api.txt',
+ section_order = {
+ 'vim.c',
+ 'vimscript.c',
+ 'command.c',
+ 'options.c',
+ 'buffer.c',
+ 'extmark.c',
+ 'window.c',
+ 'win_config.c',
+ 'tabpage.c',
+ 'autocmd.c',
+ 'ui.c',
+ },
+ exclude_types = true,
+ fn_name_pat = 'nvim_.*',
+ files = { 'src/nvim/api' },
+ section_name = {
+ ['vim.c'] = 'Global',
+ },
+ section_fmt = function(name)
+ return name .. ' Functions'
+ end,
+ helptag_fmt = function(name)
+ return fmt('api-%s', name:lower())
+ end,
+ },
+ lua = {
+ filename = 'lua.txt',
+ section_order = {
+ 'hl.lua',
+ 'diff.lua',
+ 'mpack.lua',
+ 'json.lua',
+ 'base64.lua',
+ 'spell.lua',
+ 'builtin.lua',
+ '_options.lua',
+ '_editor.lua',
+ '_inspector.lua',
+ 'shared.lua',
+ 'loader.lua',
+ 'uri.lua',
+ 'ui.lua',
+ 'filetype.lua',
+ 'keymap.lua',
+ 'fs.lua',
+ 'glob.lua',
+ 'lpeg.lua',
+ 're.lua',
+ 'regex.lua',
+ 'secure.lua',
+ 'version.lua',
+ 'iter.lua',
+ 'snippet.lua',
+ 'text.lua',
+ 'tohtml.lua',
+ },
+ files = {
+ 'runtime/lua/vim/iter.lua',
+ 'runtime/lua/vim/_editor.lua',
+ 'runtime/lua/vim/_options.lua',
+ 'runtime/lua/vim/shared.lua',
+ 'runtime/lua/vim/loader.lua',
+ 'runtime/lua/vim/uri.lua',
+ 'runtime/lua/vim/ui.lua',
+ 'runtime/lua/vim/filetype.lua',
+ 'runtime/lua/vim/keymap.lua',
+ 'runtime/lua/vim/fs.lua',
+ 'runtime/lua/vim/hl.lua',
+ 'runtime/lua/vim/secure.lua',
+ 'runtime/lua/vim/version.lua',
+ 'runtime/lua/vim/_inspector.lua',
+ 'runtime/lua/vim/snippet.lua',
+ 'runtime/lua/vim/text.lua',
+ 'runtime/lua/vim/glob.lua',
+ 'runtime/lua/vim/_meta/builtin.lua',
+ 'runtime/lua/vim/_meta/diff.lua',
+ 'runtime/lua/vim/_meta/mpack.lua',
+ 'runtime/lua/vim/_meta/json.lua',
+ 'runtime/lua/vim/_meta/base64.lua',
+ 'runtime/lua/vim/_meta/regex.lua',
+ 'runtime/lua/vim/_meta/lpeg.lua',
+ 'runtime/lua/vim/_meta/re.lua',
+ 'runtime/lua/vim/_meta/spell.lua',
+ 'runtime/lua/tohtml.lua',
+ },
+ fn_xform = function(fun)
+ if contains(fun.module, { 'vim.uri', 'vim.shared', 'vim._editor' }) then
+ fun.module = 'vim'
+ end
+
+ if fun.module == 'vim' and contains(fun.name, { 'cmd', 'inspect' }) then
+ fun.table = nil
+ end
+
+ if fun.classvar or vim.startswith(fun.name, 'vim.') or fun.module == 'vim.iter' then
+ return
+ end
+
+ fun.name = fmt('%s.%s', fun.module, fun.name)
+ end,
+ section_name = {
+ ['_inspector.lua'] = 'inspector',
+ },
+ section_fmt = function(name)
+ name = name:lower()
+ if name == '_editor' then
+ return 'Lua module: vim'
+ elseif name == '_options' then
+ return 'LUA-VIMSCRIPT BRIDGE'
+ elseif name == 'builtin' then
+ return 'VIM'
+ end
+ if
+ contains(name, {
+ 'hl',
+ 'mpack',
+ 'json',
+ 'base64',
+ 'diff',
+ 'spell',
+ 'regex',
+ 'lpeg',
+ 're',
+ })
+ then
+ return 'VIM.' .. name:upper()
+ end
+ if name == 'tohtml' then
+ return 'Lua module: tohtml'
+ end
+ return 'Lua module: vim.' .. name
+ end,
+ helptag_fmt = function(name)
+ if name == '_editor' then
+ return 'lua-vim'
+ elseif name == '_options' then
+ return 'lua-vimscript'
+ elseif name == 'tohtml' then
+ return 'tohtml'
+ end
+ return 'vim.' .. name:lower()
+ end,
+ fn_helptag_fmt = function(fun)
+ local name = fun.name
+
+ if vim.startswith(name, 'vim.') then
+ local fn_sfx = fun.table and '' or '()'
+ return name .. fn_sfx
+ elseif fun.classvar == 'Option' then
+ return fmt('vim.opt:%s()', name)
+ end
+
+ return fn_helptag_fmt_common(fun)
+ end,
+ append_only = {
+ 'shared.lua',
+ },
+ },
+ lsp = {
+ filename = 'lsp.txt',
+ section_order = {
+ 'lsp.lua',
+ 'client.lua',
+ 'buf.lua',
+ 'diagnostic.lua',
+ 'codelens.lua',
+ 'completion.lua',
+ 'folding_range.lua',
+ 'inlay_hint.lua',
+ 'tagfunc.lua',
+ 'semantic_tokens.lua',
+ 'handlers.lua',
+ 'util.lua',
+ 'log.lua',
+ 'rpc.lua',
+ 'protocol.lua',
+ },
+ files = {
+ 'runtime/lua/vim/lsp',
+ 'runtime/lua/vim/lsp.lua',
+ },
+ fn_xform = function(fun)
+ fun.name = fun.name:gsub('result%.', '')
+ if fun.module == 'vim.lsp.protocol' then
+ fun.classvar = nil
+ end
+ end,
+ section_fmt = function(name)
+ if name:lower() == 'lsp' then
+ return 'Lua module: vim.lsp'
+ end
+ return 'Lua module: vim.lsp.' .. name:lower()
+ end,
+ helptag_fmt = function(name)
+ if name:lower() == 'lsp' then
+ return 'lsp-core'
+ end
+ return fmt('lsp-%s', name:lower())
+ end,
+ },
+ diagnostic = {
+ filename = 'diagnostic.txt',
+ section_order = {
+ 'diagnostic.lua',
+ },
+ files = { 'runtime/lua/vim/diagnostic.lua' },
+ section_fmt = function()
+ return 'Lua module: vim.diagnostic'
+ end,
+ helptag_fmt = function()
+ return 'diagnostic-api'
+ end,
+ },
+ treesitter = {
+ filename = 'treesitter.txt',
+ section_order = {
+ 'tstree.lua',
+ 'tsnode.lua',
+ 'treesitter.lua',
+ 'language.lua',
+ 'query.lua',
+ 'highlighter.lua',
+ 'languagetree.lua',
+ 'dev.lua',
+ },
+ files = {
+ 'runtime/lua/vim/treesitter/_meta/',
+ 'runtime/lua/vim/treesitter.lua',
+ 'runtime/lua/vim/treesitter/',
+ },
+ section_fmt = function(name)
+ if name:lower() == 'treesitter' then
+ return 'Lua module: vim.treesitter'
+ elseif name:lower() == 'tstree' then
+ return 'TREESITTER TREES'
+ elseif name:lower() == 'tsnode' then
+ return 'TREESITTER NODES'
+ end
+ return 'Lua module: vim.treesitter.' .. name:lower()
+ end,
+ helptag_fmt = function(name)
+ if name:lower() == 'treesitter' then
+ return 'lua-treesitter-core'
+ elseif name:lower() == 'query' then
+ return 'lua-treesitter-query'
+ elseif name:lower() == 'tstree' then
+ return { 'treesitter-tree', 'TSTree' }
+ elseif name:lower() == 'tsnode' then
+ return { 'treesitter-node', 'TSNode' }
+ end
+ return 'treesitter-' .. name:lower()
+ end,
+ },
+ editorconfig = {
+ filename = 'editorconfig.txt',
+ files = {
+ 'runtime/lua/editorconfig.lua',
+ },
+ section_order = {
+ 'editorconfig.lua',
+ },
+ section_fmt = function(_name)
+ return 'EditorConfig integration'
+ end,
+ helptag_fmt = function(name)
+ return name:lower()
+ end,
+ fn_xform = function(fun)
+ fun.table = true
+ fun.name = vim.split(fun.name, '.', { plain = true })[2]
+ end,
+ },
+ health = {
+ filename = 'health.txt',
+ files = {
+ 'runtime/lua/vim/health.lua',
+ },
+ section_order = {
+ 'health.lua',
+ },
+ section_fmt = function(_name)
+ return 'Checkhealth'
+ end,
+ helptag_fmt = function()
+ return { 'vim.health', 'health' }
+ end,
+ },
+}
+
+--- @param ty string
+--- @param generics table<string,string>
+--- @return string
+local function replace_generics(ty, generics)
+ if ty:sub(-2) == '[]' then
+ local ty0 = ty:sub(1, -3)
+ if generics[ty0] then
+ return generics[ty0] .. '[]'
+ end
+ elseif ty:sub(-1) == '?' then
+ local ty0 = ty:sub(1, -2)
+ if generics[ty0] then
+ return generics[ty0] .. '?'
+ end
+ end
+
+ return generics[ty] or ty
+end
+
+--- @param name string
+local function fmt_field_name(name)
+ local name0, opt = name:match('^([^?]*)(%??)$')
+ return fmt('{%s}%s', name0, opt)
+end
+
+--- @param ty string
+--- @param generics? table<string,string>
+--- @param default? string
+local function render_type(ty, generics, default)
+ if generics then
+ ty = replace_generics(ty, generics)
+ end
+ ty = ty:gsub('%s*|%s*nil', '?')
+ ty = ty:gsub('nil%s*|%s*(.*)', '%1?')
+ ty = ty:gsub('%s*|%s*', '|')
+ if default then
+ return fmt('(`%s`, default: %s)', ty, default)
+ end
+ return fmt('(`%s`)', ty)
+end
+
+--- @param p nvim.luacats.parser.param|nvim.luacats.parser.field
+local function should_render_field_or_param(p)
+ return not p.nodoc
+ and not p.access
+ and not contains(p.name, { '_', 'self' })
+ and not vim.startswith(p.name, '_')
+end
+
+--- @param desc? string
+--- @return string?, string?
+local function get_default(desc)
+ if not desc then
+ return
+ end
+
+ local default = desc:match('\n%s*%([dD]efault: ([^)]+)%)')
+ if default then
+ desc = desc:gsub('\n%s*%([dD]efault: [^)]+%)', '')
+ end
+
+ return desc, default
+end
+
+--- @param ty string
+--- @param classes? table<string,nvim.luacats.parser.class>
+--- @return nvim.luacats.parser.class?
+local function get_class(ty, classes)
+ if not classes then
+ return
+ end
+
+ local cty = ty:gsub('%s*|%s*nil', '?'):gsub('?$', ''):gsub('%[%]$', '')
+
+ return classes[cty]
+end
+
+--- @param obj nvim.luacats.parser.param|nvim.luacats.parser.return|nvim.luacats.parser.field
+--- @param classes? table<string,nvim.luacats.parser.class>
+local function inline_type(obj, classes)
+ local ty = obj.type
+ if not ty then
+ return
+ end
+
+ local cls = get_class(ty, classes)
+
+ if not cls or cls.nodoc then
+ return
+ end
+
+ if not cls.inlinedoc then
+ -- Not inlining so just add a: "See |tag|."
+ local tag = fmt('|%s|', cls.name)
+ if obj.desc and obj.desc:find(tag) then
+ -- Tag already there
+ return
+ end
+
+ -- TODO(lewis6991): Aim to remove this. Need this to prevent dead
+ -- references to types defined in runtime/lua/vim/lsp/_meta/protocol.lua
+ if not vim.startswith(cls.name, 'vim.') then
+ return
+ end
+
+ obj.desc = obj.desc or ''
+ local period = (obj.desc == '' or vim.endswith(obj.desc, '.')) and '' or '.'
+ obj.desc = obj.desc .. fmt('%s See %s.', period, tag)
+ return
+ end
+
+ local ty_isopt = (ty:match('%?$') or ty:match('%s*|%s*nil')) ~= nil
+ local ty_islist = (ty:match('%[%]$')) ~= nil
+ ty = ty_isopt and 'table?' or ty_islist and 'table[]' or 'table'
+
+ local desc = obj.desc or ''
+ if cls.desc then
+ desc = desc .. cls.desc
+ elseif desc == '' then
+ if ty_islist then
+ desc = desc .. 'A list of objects with the following fields:'
+ elseif cls.parent then
+ desc = desc .. fmt('Extends |%s| with the additional fields:', cls.parent)
+ else
+ desc = desc .. 'A table with the following fields:'
+ end
+ end
+
+ local desc_append = {}
+ for _, f in ipairs(cls.fields) do
+ if not f.access then
+ local fdesc, default = get_default(f.desc)
+ local fty = render_type(f.type, nil, default)
+ local fnm = fmt_field_name(f.name)
+ table.insert(desc_append, table.concat({ '-', fnm, fty, fdesc }, ' '))
+ end
+ end
+
+ desc = desc .. '\n' .. table.concat(desc_append, '\n')
+ obj.type = ty
+ obj.desc = desc
+end
+
+--- @param xs (nvim.luacats.parser.param|nvim.luacats.parser.field)[]
+--- @param generics? table<string,string>
+--- @param classes? table<string,nvim.luacats.parser.class>
+--- @param exclude_types? true
+--- @param cfg nvim.gen_vimdoc.Config
+local function render_fields_or_params(xs, generics, classes, exclude_types, cfg)
+ local ret = {} --- @type string[]
+
+ xs = vim.tbl_filter(should_render_field_or_param, xs)
+
+ local indent = 0
+ for _, p in ipairs(xs) do
+ if p.type or p.desc then
+ indent = math.max(indent, #p.name + 3)
+ end
+ if exclude_types then
+ p.type = nil
+ end
+ end
+
+ for _, p in ipairs(xs) do
+ local pdesc, default = get_default(p.desc)
+ p.desc = pdesc
+
+ inline_type(p, classes)
+ local nm, ty = p.name, p.type
+
+ local desc = p.classvar and string.format('See |%s|.', cfg.fn_helptag_fmt(p)) or p.desc
+
+ local fnm = p.kind == 'operator' and fmt('op(%s)', nm) or fmt_field_name(nm)
+ local pnm = fmt(' • %-' .. indent .. 's', fnm)
+
+ if ty then
+ local pty = render_type(ty, generics, default)
+
+ if desc then
+ table.insert(ret, pnm)
+ if #pty > TEXT_WIDTH - indent then
+ vim.list_extend(ret, { ' ', pty, '\n' })
+ table.insert(ret, md_to_vimdoc(desc, 9 + indent, 9 + indent, TEXT_WIDTH, true))
+ else
+ desc = fmt('%s %s', pty, desc)
+ table.insert(ret, md_to_vimdoc(desc, 1, 9 + indent, TEXT_WIDTH, true))
+ end
+ else
+ table.insert(ret, fmt('%s %s\n', pnm, pty))
+ end
+ else
+ if desc then
+ table.insert(ret, pnm)
+ table.insert(ret, md_to_vimdoc(desc, 1, 9 + indent, TEXT_WIDTH, true))
+ end
+ end
+ end
+
+ return table.concat(ret)
+end
+
+--- @param class nvim.luacats.parser.class
+--- @param classes table<string,nvim.luacats.parser.class>
+--- @param cfg nvim.gen_vimdoc.Config
+local function render_class(class, classes, cfg)
+ if class.access or class.nodoc or class.inlinedoc then
+ return
+ end
+
+ local ret = {} --- @type string[]
+
+ table.insert(ret, fmt('*%s*\n', class.name))
+
+ if class.parent then
+ local txt = fmt('Extends: |%s|', class.parent)
+ table.insert(ret, md_to_vimdoc(txt, INDENTATION, INDENTATION, TEXT_WIDTH))
+ table.insert(ret, '\n')
+ end
+
+ if class.desc then
+ table.insert(ret, md_to_vimdoc(class.desc, INDENTATION, INDENTATION, TEXT_WIDTH))
+ end
+
+ local fields_txt = render_fields_or_params(class.fields, nil, classes, nil, cfg)
+ if not fields_txt:match('^%s*$') then
+ table.insert(ret, '\n Fields: ~\n')
+ table.insert(ret, fields_txt)
+ end
+ table.insert(ret, '\n')
+
+ return table.concat(ret)
+end
+
+--- @param classes table<string,nvim.luacats.parser.class>
+--- @param cfg nvim.gen_vimdoc.Config
+local function render_classes(classes, cfg)
+ local ret = {} --- @type string[]
+
+ for _, class in vim.spairs(classes) do
+ ret[#ret + 1] = render_class(class, classes, cfg)
+ end
+
+ return table.concat(ret)
+end
+
+--- @param fun nvim.luacats.parser.fun
+--- @param cfg nvim.gen_vimdoc.Config
+local function render_fun_header(fun, cfg)
+ local ret = {} --- @type string[]
+
+ local args = {} --- @type string[]
+ for _, p in ipairs(fun.params or {}) do
+ if p.name ~= 'self' then
+ args[#args + 1] = fmt_field_name(p.name)
+ end
+ end
+
+ local nm = fun.name
+ if fun.classvar then
+ nm = fmt('%s:%s', fun.classvar, nm)
+ end
+ if nm == 'vim.bo' then
+ nm = 'vim.bo[{bufnr}]'
+ end
+ if nm == 'vim.wo' then
+ nm = 'vim.wo[{winid}][{bufnr}]'
+ end
+
+ local proto = fun.table and nm or nm .. '(' .. table.concat(args, ', ') .. ')'
+
+ local tag = '*' .. cfg.fn_helptag_fmt(fun) .. '*'
+
+ if #proto + #tag > TEXT_WIDTH - 8 then
+ table.insert(ret, fmt('%78s\n', tag))
+ local name, pargs = proto:match('([^(]+%()(.*)')
+ table.insert(ret, name)
+ table.insert(ret, wrap(pargs, 0, #name, TEXT_WIDTH))
+ else
+ local pad = TEXT_WIDTH - #proto - #tag
+ table.insert(ret, proto .. string.rep(' ', pad) .. tag)
+ end
+
+ return table.concat(ret)
+end
+
+--- @param returns nvim.luacats.parser.return[]
+--- @param generics? table<string,string>
+--- @param classes? table<string,nvim.luacats.parser.class>
+--- @param exclude_types boolean
+local function render_returns(returns, generics, classes, exclude_types)
+ local ret = {} --- @type string[]
+
+ returns = vim.deepcopy(returns)
+ if exclude_types then
+ for _, r in ipairs(returns) do
+ r.type = nil
+ end
+ end
+
+ if #returns > 1 then
+ table.insert(ret, ' Return (multiple): ~\n')
+ elseif #returns == 1 and next(returns[1]) then
+ table.insert(ret, ' Return: ~\n')
+ end
+
+ for _, p in ipairs(returns) do
+ inline_type(p, classes)
+ local rnm, ty, desc = p.name, p.type, p.desc
+
+ local blk = {} --- @type string[]
+ if ty then
+ blk[#blk + 1] = render_type(ty, generics)
+ end
+ blk[#blk + 1] = rnm
+ blk[#blk + 1] = desc
+
+ table.insert(ret, md_to_vimdoc(table.concat(blk, ' '), 8, 8, TEXT_WIDTH, true))
+ end
+
+ return table.concat(ret)
+end
+
+--- @param fun nvim.luacats.parser.fun
+--- @param classes table<string,nvim.luacats.parser.class>
+--- @param cfg nvim.gen_vimdoc.Config
+local function render_fun(fun, classes, cfg)
+ if fun.access or fun.deprecated or fun.nodoc then
+ return
+ end
+
+ if cfg.fn_name_pat and not fun.name:match(cfg.fn_name_pat) then
+ return
+ end
+
+ if vim.startswith(fun.name, '_') or fun.name:find('[:.]_') then
+ return
+ end
+
+ local ret = {} --- @type string[]
+
+ table.insert(ret, render_fun_header(fun, cfg))
+ table.insert(ret, '\n')
+
+ if fun.since then
+ local since = assert(tonumber(fun.since), 'invalid @since on ' .. fun.name)
+ local info = nvim_api_info()
+ if since == 0 or (info.prerelease and since == info.level) then
+ -- Experimental = (since==0 or current prerelease)
+ local s = 'WARNING: This feature is experimental/unstable.'
+ table.insert(ret, md_to_vimdoc(s, INDENTATION, INDENTATION, TEXT_WIDTH))
+ table.insert(ret, '\n')
+ else
+ local v = assert(util.version_level[since], 'invalid @since on ' .. fun.name)
+ fun.attrs = fun.attrs or {}
+ table.insert(fun.attrs, ('Since: %s'):format(v))
+ end
+ end
+
+ if fun.desc then
+ table.insert(ret, md_to_vimdoc(fun.desc, INDENTATION, INDENTATION, TEXT_WIDTH))
+ end
+
+ if fun.notes then
+ table.insert(ret, '\n Note: ~\n')
+ for _, p in ipairs(fun.notes) do
+ table.insert(ret, ' • ' .. md_to_vimdoc(p.desc, 0, 8, TEXT_WIDTH, true))
+ end
+ end
+
+ if fun.attrs then
+ table.insert(ret, '\n Attributes: ~\n')
+ for _, attr in ipairs(fun.attrs) do
+ local attr_str = ({
+ textlock = 'not allowed when |textlock| is active or in the |cmdwin|',
+ textlock_allow_cmdwin = 'not allowed when |textlock| is active',
+ fast = '|api-fast|',
+ remote_only = '|RPC| only',
+ lua_only = 'Lua |vim.api| only',
+ })[attr] or attr
+ table.insert(ret, fmt(' %s\n', attr_str))
+ end
+ end
+
+ if fun.params and #fun.params > 0 then
+ local param_txt =
+ render_fields_or_params(fun.params, fun.generics, classes, cfg.exclude_types, cfg)
+ if not param_txt:match('^%s*$') then
+ table.insert(ret, '\n Parameters: ~\n')
+ ret[#ret + 1] = param_txt
+ end
+ end
+
+ if fun.returns then
+ local txt = render_returns(fun.returns, fun.generics, classes, cfg.exclude_types)
+ if not txt:match('^%s*$') then
+ table.insert(ret, '\n')
+ ret[#ret + 1] = txt
+ end
+ end
+
+ if fun.see then
+ table.insert(ret, '\n See also: ~\n')
+ for _, p in ipairs(fun.see) do
+ table.insert(ret, ' • ' .. md_to_vimdoc(p.desc, 0, 8, TEXT_WIDTH, true))
+ end
+ end
+
+ table.insert(ret, '\n')
+ return table.concat(ret)
+end
+
+--- @param funs nvim.luacats.parser.fun[]
+--- @param classes table<string,nvim.luacats.parser.class>
+--- @param cfg nvim.gen_vimdoc.Config
+local function render_funs(funs, classes, cfg)
+ local ret = {} --- @type string[]
+
+ for _, f in ipairs(funs) do
+ if cfg.fn_xform then
+ cfg.fn_xform(f)
+ end
+ ret[#ret + 1] = render_fun(f, classes, cfg)
+ end
+
+ -- Sort via prototype. Experimental API functions ("nvim__") sort last.
+ table.sort(ret, function(a, b)
+ local a1 = ('\n' .. a):match('\n[a-zA-Z_][^\n]+\n')
+ local b1 = ('\n' .. b):match('\n[a-zA-Z_][^\n]+\n')
+
+ local a1__ = a1:find('^%s*nvim__') and 1 or 0
+ local b1__ = b1:find('^%s*nvim__') and 1 or 0
+ if a1__ ~= b1__ then
+ return a1__ < b1__
+ end
+
+ return a1:lower() < b1:lower()
+ end)
+
+ return table.concat(ret)
+end
+
+--- @return string
+local function get_script_path()
+ local str = debug.getinfo(2, 'S').source:sub(2)
+ return str:match('(.*[/\\])') or './'
+end
+
+local script_path = get_script_path()
+local base_dir = vim.fs.dirname(vim.fs.dirname(vim.fs.dirname(script_path)))
+
+local function delete_lines_below(doc_file, tokenstr)
+ local lines = {} --- @type string[]
+ local found = false
+ for line in io.lines(doc_file) do
+ if line:find(vim.pesc(tokenstr)) then
+ found = true
+ break
+ end
+ lines[#lines + 1] = line
+ end
+ if not found then
+ error(fmt('not found: %s in %s', tokenstr, doc_file))
+ end
+ lines[#lines] = nil
+ local fp = assert(io.open(doc_file, 'w'))
+ fp:write(table.concat(lines, '\n'))
+ fp:write('\n')
+ fp:close()
+end
+
+--- @param x string
+local function mktitle(x)
+ if x == 'ui' then
+ return 'UI'
+ end
+ return x:sub(1, 1):upper() .. x:sub(2)
+end
+
+--- @class nvim.gen_vimdoc.Section
+--- @field name string
+--- @field title string
+--- @field help_tag string
+--- @field funs_txt string
+--- @field doc? string[]
+
+--- @param filename string
+--- @param cfg nvim.gen_vimdoc.Config
+--- @param section_docs table<string,nvim.gen_vimdoc.Section>
+--- @param funs_txt string
+--- @return nvim.gen_vimdoc.Section?
+local function make_section(filename, cfg, section_docs, funs_txt)
+ -- filename: e.g., 'autocmd.c'
+ -- name: e.g. 'autocmd'
+ local name = filename:match('(.*)%.[a-z]+')
+
+ -- Formatted (this is what's going to be written in the vimdoc)
+ -- e.g., "Autocmd Functions"
+ local sectname = cfg.section_name and cfg.section_name[filename] or mktitle(name)
+
+ -- section tag: e.g., "*api-autocmd*"
+ local help_labels = cfg.helptag_fmt(sectname)
+ if type(help_labels) == 'table' then
+ help_labels = table.concat(help_labels, '* *')
+ end
+ local help_tags = '*' .. help_labels .. '*'
+
+ if funs_txt == '' and #section_docs == 0 then
+ return
+ end
+
+ return {
+ name = sectname,
+ title = cfg.section_fmt(sectname),
+ help_tag = help_tags,
+ funs_txt = funs_txt,
+ doc = section_docs,
+ }
+end
+
+--- @param section nvim.gen_vimdoc.Section
+--- @param add_header? boolean
+local function render_section(section, add_header)
+ local doc = {} --- @type string[]
+
+ if add_header ~= false then
+ vim.list_extend(doc, {
+ string.rep('=', TEXT_WIDTH),
+ '\n',
+ section.title,
+ fmt('%' .. (TEXT_WIDTH - section.title:len()) .. 's', section.help_tag),
+ })
+ end
+
+ local sdoc = '\n\n' .. table.concat(section.doc or {}, '\n')
+ if sdoc:find('[^%s]') then
+ doc[#doc + 1] = sdoc
+ end
+
+ if section.funs_txt then
+ table.insert(doc, '\n\n')
+ table.insert(doc, section.funs_txt)
+ end
+
+ return table.concat(doc)
+end
+
+local parsers = {
+ lua = luacats_parser.parse,
+ c = cdoc_parser.parse,
+ h = cdoc_parser.parse,
+}
+
+--- @param files string[]
+local function expand_files(files)
+ for k, f in pairs(files) do
+ if vim.fn.isdirectory(f) == 1 then
+ table.remove(files, k)
+ for path, ty in vim.fs.dir(f) do
+ if ty == 'file' then
+ table.insert(files, vim.fs.joinpath(f, path))
+ end
+ end
+ end
+ end
+end
+
+--- @param cfg nvim.gen_vimdoc.Config
+local function gen_target(cfg)
+ cfg.fn_helptag_fmt = cfg.fn_helptag_fmt or fn_helptag_fmt_common
+ print('Target:', cfg.filename)
+ local sections = {} --- @type table<string,nvim.gen_vimdoc.Section>
+
+ expand_files(cfg.files)
+
+ --- @type table<string,[table<string,nvim.luacats.parser.class>, nvim.luacats.parser.fun[], string[]]>
+ local file_results = {}
+
+ --- @type table<string,nvim.luacats.parser.class>
+ local all_classes = {}
+
+ --- First pass so we can collect all classes
+ for _, f in vim.spairs(cfg.files) do
+ local ext = assert(f:match('%.([^.]+)$')) --[[@as 'h'|'c'|'lua']]
+ local parser = assert(parsers[ext])
+ local classes, funs, briefs = parser(f)
+ file_results[f] = { classes, funs, briefs }
+ all_classes = vim.tbl_extend('error', all_classes, classes)
+ end
+
+ for f, r in vim.spairs(file_results) do
+ local classes, funs, briefs = r[1], r[2], r[3]
+
+ local briefs_txt = {} --- @type string[]
+ for _, b in ipairs(briefs) do
+ briefs_txt[#briefs_txt + 1] = md_to_vimdoc(b, 0, 0, TEXT_WIDTH)
+ end
+ print(' Processing file:', f)
+ local funs_txt = render_funs(funs, all_classes, cfg)
+ if next(classes) then
+ local classes_txt = render_classes(classes, cfg)
+ if vim.trim(classes_txt) ~= '' then
+ funs_txt = classes_txt .. '\n' .. funs_txt
+ end
+ end
+ -- FIXME: Using f_base will confuse `_meta/protocol.lua` with `protocol.lua`
+ local f_base = vim.fs.basename(f)
+ sections[f_base] = make_section(f_base, cfg, briefs_txt, funs_txt)
+ end
+
+ local first_section_tag = sections[cfg.section_order[1]].help_tag
+ local docs = {} --- @type string[]
+ for _, f in ipairs(cfg.section_order) do
+ local section = sections[f]
+ if section then
+ print(string.format(" Rendering section: '%s'", section.title))
+ local add_sep_and_header = not vim.tbl_contains(cfg.append_only or {}, f)
+ docs[#docs + 1] = render_section(section, add_sep_and_header)
+ end
+ end
+
+ table.insert(
+ docs,
+ fmt(' vim:tw=78:ts=8:sw=%d:sts=%d:et:ft=help:norl:\n', INDENTATION, INDENTATION)
+ )
+
+ local doc_file = vim.fs.joinpath(base_dir, 'runtime', 'doc', cfg.filename)
+
+ if vim.uv.fs_stat(doc_file) then
+ delete_lines_below(doc_file, first_section_tag)
+ end
+
+ local fp = assert(io.open(doc_file, 'a'))
+ fp:write(table.concat(docs, '\n'))
+ fp:close()
+end
+
+local function run()
+ for _, cfg in vim.spairs(config) do
+ gen_target(cfg)
+ end
+end
+
+run()
diff --git a/src/gen/gen_vimvim.lua b/src/gen/gen_vimvim.lua
new file mode 100644
index 0000000000..d2b1f48a4c
--- /dev/null
+++ b/src/gen/gen_vimvim.lua
@@ -0,0 +1,156 @@
+local mpack = vim.mpack
+
+local syntax_file = arg[1]
+local funcs_file = arg[2]
+
+local lld = {}
+local syn_fd = io.open(syntax_file, 'w')
+lld.line_length = 0
+local function w(s)
+ syn_fd:write(s)
+ if s:find('\n') then
+ lld.line_length = #(s:gsub('.*\n', ''))
+ else
+ lld.line_length = lld.line_length + #s
+ end
+end
+
+local options = require('nvim.options')
+local auevents = require('nvim.auevents')
+local ex_cmds = require('nvim.ex_cmds')
+
+local function cmd_kw(prev_cmd, cmd)
+ if not prev_cmd then
+ return cmd:sub(1, 1) .. '[' .. cmd:sub(2) .. ']'
+ else
+ local shift = 1
+ while cmd:sub(shift, shift) == prev_cmd:sub(shift, shift) do
+ shift = shift + 1
+ end
+ if cmd:sub(1, shift) == 'def' then
+ shift = shift + 1
+ end
+ if shift >= #cmd then
+ return cmd
+ else
+ return cmd:sub(1, shift) .. '[' .. cmd:sub(shift + 1) .. ']'
+ end
+ end
+end
+
+-- Exclude these from the vimCommand keyword list, they are handled specially
+-- in syntax/vim.vim (vimAugroupKey, vimAutoCmd, vimGlobal, vimSubst). #9327
+local function is_special_cased_cmd(cmd)
+ return (
+ cmd == 'augroup'
+ or cmd == 'autocmd'
+ or cmd == 'doautocmd'
+ or cmd == 'doautoall'
+ or cmd == 'global'
+ or cmd == 'substitute'
+ )
+end
+
+local vimcmd_start = 'syn keyword vimCommand contained '
+local vimcmd_end = ' nextgroup=vimBang'
+w(vimcmd_start)
+
+local prev_cmd = nil
+for _, cmd_desc in ipairs(ex_cmds.cmds) do
+ if lld.line_length > 850 then
+ w(vimcmd_end .. '\n' .. vimcmd_start)
+ end
+ local cmd = cmd_desc.command
+ if cmd:match('%w') and cmd ~= 'z' and not is_special_cased_cmd(cmd) then
+ w(' ' .. cmd_kw(prev_cmd, cmd))
+ end
+ if cmd == 'delete' then
+ -- Add special abbreviations of :delete
+ w(' ' .. cmd_kw('d', 'dl'))
+ w(' ' .. cmd_kw('del', 'dell'))
+ w(' ' .. cmd_kw('dele', 'delel'))
+ w(' ' .. cmd_kw('delet', 'deletl'))
+ w(' ' .. cmd_kw('delete', 'deletel'))
+ w(' ' .. cmd_kw('d', 'dp'))
+ w(' ' .. cmd_kw('de', 'dep'))
+ w(' ' .. cmd_kw('del', 'delp'))
+ w(' ' .. cmd_kw('dele', 'delep'))
+ w(' ' .. cmd_kw('delet', 'deletp'))
+ w(' ' .. cmd_kw('delete', 'deletep'))
+ end
+ prev_cmd = cmd
+end
+
+w(vimcmd_end .. '\n')
+
+local vimopt_start = 'syn keyword vimOption contained '
+local vimopt_end = ' skipwhite nextgroup=vimSetEqual,vimSetMod'
+w('\n' .. vimopt_start)
+
+for _, opt_desc in ipairs(options.options) do
+ if not opt_desc.immutable then
+ if lld.line_length > 850 then
+ w(vimopt_end .. '\n' .. vimopt_start)
+ end
+ w(' ' .. opt_desc.full_name)
+ if opt_desc.abbreviation then
+ w(' ' .. opt_desc.abbreviation)
+ end
+ if opt_desc.type == 'boolean' then
+ w(' inv' .. opt_desc.full_name)
+ w(' no' .. opt_desc.full_name)
+ if opt_desc.abbreviation then
+ w(' inv' .. opt_desc.abbreviation)
+ w(' no' .. opt_desc.abbreviation)
+ end
+ end
+ end
+end
+
+w(vimopt_end .. '\n')
+
+w('\nsyn case ignore')
+local vimau_start = 'syn keyword vimAutoEvent contained '
+w('\n\n' .. vimau_start)
+
+for _, au in ipairs(auevents.events) do
+ if not auevents.nvim_specific[au[1]] then
+ if lld.line_length > 850 then
+ w('\n' .. vimau_start)
+ end
+ w(' ' .. au[1])
+ for _, alias in ipairs(au[2]) do
+ if lld.line_length > 850 then
+ w('\n' .. vimau_start)
+ end
+ -- au[1] is aliased to alias
+ w(' ' .. alias)
+ end
+ end
+end
+
+local nvimau_start = 'syn keyword nvimAutoEvent contained '
+w('\n\n' .. nvimau_start)
+
+for au, _ in vim.spairs(auevents.nvim_specific) do
+ if lld.line_length > 850 then
+ w('\n' .. nvimau_start)
+ end
+ w(' ' .. au)
+end
+
+w('\n\nsyn case match')
+local vimfun_start = 'syn keyword vimFuncName contained '
+w('\n\n' .. vimfun_start)
+local funcs = mpack.decode(io.open(funcs_file, 'rb'):read('*all'))
+for _, name in ipairs(funcs) do
+ if name then
+ if lld.line_length > 850 then
+ w('\n' .. vimfun_start)
+ end
+ w(' ' .. name)
+ end
+end
+
+w('\n')
+syn_fd:close()
diff --git a/src/gen/hashy.lua b/src/gen/hashy.lua
new file mode 100644
index 0000000000..74b7655324
--- /dev/null
+++ b/src/gen/hashy.lua
@@ -0,0 +1,145 @@
+-- HASHY McHASHFACE
+
+local M = {}
+_G.d = M
+
+local function setdefault(table, key)
+ local val = table[key]
+ if val == nil then
+ val = {}
+ table[key] = val
+ end
+ return val
+end
+
+function M.build_pos_hash(strings)
+ local len_buckets = {}
+ local maxlen = 0
+ for _, s in ipairs(strings) do
+ table.insert(setdefault(len_buckets, #s), s)
+ if #s > maxlen then
+ maxlen = #s
+ end
+ end
+
+ local len_pos_buckets = {}
+ local worst_buck_size = 0
+
+ for len = 1, maxlen do
+ local strs = len_buckets[len]
+ if strs then
+ -- the best position so far generates `best_bucket`
+ -- with `minsize` worst case collisions
+ local bestpos, minsize, best_bucket = nil, #strs * 2, nil
+ for pos = 1, len do
+ local try_bucket = {}
+ for _, str in ipairs(strs) do
+ local poschar = string.sub(str, pos, pos)
+ table.insert(setdefault(try_bucket, poschar), str)
+ end
+ local maxsize = 1
+ for _, pos_strs in pairs(try_bucket) do
+ maxsize = math.max(maxsize, #pos_strs)
+ end
+ if maxsize < minsize then
+ bestpos = pos
+ minsize = maxsize
+ best_bucket = try_bucket
+ end
+ end
+ len_pos_buckets[len] = { bestpos, best_bucket }
+ worst_buck_size = math.max(worst_buck_size, minsize)
+ end
+ end
+ return len_pos_buckets, maxlen, worst_buck_size
+end
+
+function M.switcher(put, tab, maxlen, worst_buck_size)
+ local neworder = {} --- @type string[]
+ put ' switch (len) {\n'
+ local bucky = worst_buck_size > 1
+ for len = 1, maxlen do
+ local vals = tab[len]
+ if vals then
+ put(' case ' .. len .. ': ')
+ local pos, posbuck = unpack(vals)
+ local keys = vim.tbl_keys(posbuck)
+ if #keys > 1 then
+ table.sort(keys)
+ put('switch (str[' .. (pos - 1) .. ']) {\n')
+ for _, c in ipairs(keys) do
+ local buck = posbuck[c]
+ local startidx = #neworder
+ vim.list_extend(neworder, buck)
+ local endidx = #neworder
+ put(" case '" .. c .. "': ")
+ if len == 1 then
+ put('return ' .. startidx .. ';\n')
+ else
+ put('low = ' .. startidx .. '; ')
+ if bucky then
+ put('high = ' .. endidx .. '; ')
+ end
+ put 'break;\n'
+ end
+ end
+ put ' default: break;\n'
+ put ' }\n '
+ else
+ local startidx = #neworder
+ table.insert(neworder, posbuck[keys[1]][1])
+ local endidx = #neworder
+ put('low = ' .. startidx .. '; ')
+ if bucky then
+ put('high = ' .. endidx .. '; ')
+ end
+ end
+ put 'break;\n'
+ end
+ end
+ put ' default: break;\n'
+ put ' }\n'
+ return neworder
+end
+
+function M.hashy_hash(name, strings, access)
+ local stats = {}
+ local put = function(str)
+ table.insert(stats, str)
+ end
+ local len_pos_buckets, maxlen, worst_buck_size = M.build_pos_hash(strings)
+ put('int ' .. name .. '_hash(const char *str, size_t len)\n{\n')
+ if maxlen == 1 then
+ put('\n') -- nothing
+ elseif worst_buck_size > 1 then
+ put(' int low = 0, high = 0;\n')
+ else
+ put(' int low = -1;\n')
+ end
+ local neworder = M.switcher(put, len_pos_buckets, maxlen, worst_buck_size)
+ if maxlen == 1 then
+ put([[
+ return -1;
+]])
+ elseif worst_buck_size > 1 then
+ put([[
+ for (int i = low; i < high; i++) {
+ if (!memcmp(str, ]] .. access('i') .. [[, len)) {
+ return i;
+ }
+ }
+ return -1;
+]])
+ else
+ put([[
+ if (low < 0 || memcmp(str, ]] .. access('low') .. [[, len)) {
+ return -1;
+ }
+ return low;
+]])
+ end
+ put '}\n\n'
+ return neworder, table.concat(stats)
+end
+
+return M
diff --git a/src/gen/luacats_grammar.lua b/src/gen/luacats_grammar.lua
new file mode 100644
index 0000000000..b700bcf58f
--- /dev/null
+++ b/src/gen/luacats_grammar.lua
@@ -0,0 +1,207 @@
+--[[!
+LPEG grammar for LuaCATS
+]]
+
+local lpeg = vim.lpeg
+local P, R, S = lpeg.P, lpeg.R, lpeg.S
+local C, Ct, Cg = lpeg.C, lpeg.Ct, lpeg.Cg
+
+--- @param x vim.lpeg.Pattern
+local function rep(x)
+ return x ^ 0
+end
+
+--- @param x vim.lpeg.Pattern
+local function rep1(x)
+ return x ^ 1
+end
+
+--- @param x vim.lpeg.Pattern
+local function opt(x)
+ return x ^ -1
+end
+
+local ws = rep1(S(' \t'))
+local fill = opt(ws)
+local any = P(1) -- (consume one character)
+local letter = R('az', 'AZ')
+local num = R('09')
+
+--- @param x string | vim.lpeg.Pattern
+local function Pf(x)
+ return fill * P(x) * fill
+end
+
+--- @param x string | vim.lpeg.Pattern
+local function Plf(x)
+ return fill * P(x)
+end
+
+--- @param x string
+local function Sf(x)
+ return fill * S(x) * fill
+end
+
+--- @param x vim.lpeg.Pattern
+local function paren(x)
+ return Pf('(') * x * fill * P(')')
+end
+
+--- @param x vim.lpeg.Pattern
+local function parenOpt(x)
+ return paren(x) + x
+end
+
+--- @param x vim.lpeg.Pattern
+local function comma1(x)
+ return parenOpt(x * rep(Pf(',') * x))
+end
+
+--- @param x vim.lpeg.Pattern
+local function comma(x)
+ return opt(comma1(x))
+end
+
+--- @type table<string,vim.lpeg.Pattern>
+local v = setmetatable({}, {
+ __index = function(_, k)
+ return lpeg.V(k)
+ end,
+})
+
+--- @class nvim.luacats.Param
+--- @field kind 'param'
+--- @field name string
+--- @field type string
+--- @field desc? string
+
+--- @class nvim.luacats.Return
+--- @field kind 'return'
+--- @field [integer] { type: string, name?: string}
+--- @field desc? string
+
+--- @class nvim.luacats.Generic
+--- @field kind 'generic'
+--- @field name string
+--- @field type? string
+
+--- @class nvim.luacats.Class
+--- @field kind 'class'
+--- @field name string
+--- @field parent? string
+--- @field access? 'private'|'protected'|'package'
+
+--- @class nvim.luacats.Field
+--- @field kind 'field'
+--- @field name string
+--- @field type string
+--- @field desc? string
+--- @field access? 'private'|'protected'|'package'
+
+--- @class nvim.luacats.Note
+--- @field desc? string
+
+--- @alias nvim.luacats.grammar.result
+--- | nvim.luacats.Param
+--- | nvim.luacats.Return
+--- | nvim.luacats.Generic
+--- | nvim.luacats.Class
+--- | nvim.luacats.Field
+--- | nvim.luacats.Note
+
+--- @class nvim.luacats.grammar
+--- @field match fun(self, input: string): nvim.luacats.grammar.result?
+
+local function annot(nm, pat)
+ if type(nm) == 'string' then
+ nm = P(nm)
+ end
+ if pat then
+ return Ct(Cg(P(nm), 'kind') * fill * pat)
+ end
+ return Ct(Cg(P(nm), 'kind'))
+end
+
+local colon = Pf(':')
+local ellipsis = P('...')
+local ident_first = P('_') + letter
+local ident = ident_first * rep(ident_first + num)
+local opt_ident = ident * opt(P('?'))
+local ty_ident_sep = S('-._')
+local ty_ident = ident * rep(ty_ident_sep * ident)
+local string_single = P "'" * rep(any - P "'") * P "'"
+local string_double = P('"') * rep(any - P('"')) * P('"')
+local generic = P('`') * ty_ident * P('`')
+local literal = string_single + string_double + (opt(P('-')) * rep1(num)) + P('false') + P('true')
+local ty_prims = ty_ident + literal + generic
+
+local array_postfix = rep1(Plf('[]'))
+local opt_postfix = rep1(Plf('?'))
+local rep_array_opt_postfix = rep(array_postfix + opt_postfix)
+
+local typedef = P({
+ 'typedef',
+ typedef = C(v.type),
+
+ type = v.ty * rep_array_opt_postfix * rep(Pf('|') * v.ty * rep_array_opt_postfix),
+ ty = v.composite + paren(v.typedef),
+ composite = (v.types * array_postfix) + (v.types * opt_postfix) + v.types,
+ types = v.generics + v.kv_table + v.tuple + v.dict + v.table_literal + v.fun + ty_prims,
+
+ tuple = Pf('[') * comma1(v.type) * Plf(']'),
+ dict = Pf('{') * comma1(Pf('[') * v.type * Pf(']') * colon * v.type) * Plf('}'),
+ kv_table = Pf('table') * Pf('<') * v.type * Pf(',') * v.type * Plf('>'),
+ table_literal = Pf('{') * comma1(opt_ident * Pf(':') * v.type) * Plf('}'),
+ fun_param = (opt_ident + ellipsis) * opt(colon * v.type),
+ fun_ret = v.type + (ellipsis * opt(colon * v.type)),
+ fun = Pf('fun') * paren(comma(v.fun_param)) * opt(Pf(':') * comma1(v.fun_ret)),
+ generics = P(ty_ident) * Pf('<') * comma1(v.type) * Plf('>'),
+}) / function(match)
+ return vim.trim(match):gsub('^%((.*)%)$', '%1'):gsub('%?+', '?')
+end
+
+local access = P('private') + P('protected') + P('package')
+local caccess = Cg(access, 'access')
+local cattr = Cg(comma(access + P('exact')), 'access')
+local desc_delim = Sf '#:' + ws
+local desc = Cg(rep(any), 'desc')
+local opt_desc = opt(desc_delim * desc)
+local ty_name = Cg(ty_ident, 'name')
+local opt_parent = opt(colon * Cg(ty_ident, 'parent'))
+local lname = (ident + ellipsis) * opt(P('?'))
+
+local grammar = P {
+ rep1(P('@') * (v.ats + v.ext_ats)),
+
+ ats = annot('param', Cg(lname, 'name') * ws * v.ctype * opt_desc)
+ + annot('return', comma1(Ct(v.ctype * opt(ws * (ty_name + Cg(ellipsis, 'name'))))) * opt_desc)
+ + annot('type', comma1(Ct(v.ctype)) * opt_desc)
+ + annot('cast', ty_name * ws * opt(Sf('+-')) * v.ctype)
+ + annot('generic', ty_name * opt(colon * v.ctype))
+ + annot('class', opt(paren(cattr)) * fill * ty_name * opt_parent)
+ + annot('field', opt(caccess * ws) * v.field_name * ws * v.ctype * opt_desc)
+ + annot('operator', ty_name * opt(paren(Cg(v.ctype, 'argtype'))) * colon * v.ctype)
+ + annot(access)
+ + annot('deprecated')
+ + annot('alias', ty_name * opt(ws * v.ctype))
+ + annot('enum', ty_name)
+ + annot('overload', v.ctype)
+ + annot('see', opt(desc_delim) * desc)
+ + annot('diagnostic', opt(desc_delim) * desc)
+ + annot('meta'),
+
+ --- Custom extensions
+ ext_ats = (
+ annot('note', desc)
+ + annot('since', desc)
+ + annot('nodoc')
+ + annot('inlinedoc')
+ + annot('brief', desc)
+ ),
+
+ field_name = Cg(lname + (v.ty_index * opt(P('?'))), 'name'),
+ ty_index = C(Pf('[') * typedef * fill * P(']')),
+ ctype = Cg(typedef, 'type'),
+}
+
+return grammar --[[@as nvim.luacats.grammar]]
diff --git a/src/gen/luacats_parser.lua b/src/gen/luacats_parser.lua
new file mode 100644
index 0000000000..36bdc44076
--- /dev/null
+++ b/src/gen/luacats_parser.lua
@@ -0,0 +1,535 @@
+local luacats_grammar = require('gen.luacats_grammar')
+
+--- @class nvim.luacats.parser.param : nvim.luacats.Param
+
+--- @class nvim.luacats.parser.return
+--- @field name string
+--- @field type string
+--- @field desc string
+
+--- @class nvim.luacats.parser.note
+--- @field desc string
+
+--- @class nvim.luacats.parser.brief
+--- @field kind 'brief'
+--- @field desc string
+
+--- @class nvim.luacats.parser.alias
+--- @field kind 'alias'
+--- @field type string[]
+--- @field desc string
+
+--- @class nvim.luacats.parser.fun
+--- @field name string
+--- @field params nvim.luacats.parser.param[]
+--- @field returns nvim.luacats.parser.return[]
+--- @field desc string
+--- @field access? 'private'|'package'|'protected'
+--- @field class? string
+--- @field module? string
+--- @field modvar? string
+--- @field classvar? string
+--- @field deprecated? true
+--- @field since? string
+--- @field attrs? string[]
+--- @field nodoc? true
+--- @field generics? table<string,string>
+--- @field table? true
+--- @field notes? nvim.luacats.parser.note[]
+--- @field see? nvim.luacats.parser.note[]
+
+--- @class nvim.luacats.parser.field : nvim.luacats.Field
+--- @field classvar? string
+--- @field nodoc? true
+
+--- @class nvim.luacats.parser.class : nvim.luacats.Class
+--- @field desc? string
+--- @field nodoc? true
+--- @field inlinedoc? true
+--- @field fields nvim.luacats.parser.field[]
+--- @field notes? string[]
+
+--- @class nvim.luacats.parser.State
+--- @field doc_lines? string[]
+--- @field cur_obj? nvim.luacats.parser.obj
+--- @field last_doc_item? nvim.luacats.parser.param|nvim.luacats.parser.return|nvim.luacats.parser.note
+--- @field last_doc_item_indent? integer
+
+--- @alias nvim.luacats.parser.obj
+--- | nvim.luacats.parser.class
+--- | nvim.luacats.parser.fun
+--- | nvim.luacats.parser.brief
+--- | nvim.luacats.parser.alias
+
+-- Remove this when we document classes properly
+--- Some doc lines have the form:
+--- param name some.complex.type (table) description
+--- if so then transform the line to remove the complex type:
+--- param name (table) description
+--- @param line string
+local function use_type_alt(line)
+ for _, type in ipairs({ 'table', 'function' }) do
+ line = line:gsub('@param%s+([a-zA-Z_?]+)%s+.*%((' .. type .. ')%)', '@param %1 %2')
+ line = line:gsub('@param%s+([a-zA-Z_?]+)%s+.*%((' .. type .. '|nil)%)', '@param %1 %2')
+ line = line:gsub('@param%s+([a-zA-Z_?]+)%s+.*%((' .. type .. '%?)%)', '@param %1 %2')
+
+ line = line:gsub('@return%s+.*%((' .. type .. ')%)', '@return %1')
+ line = line:gsub('@return%s+.*%((' .. type .. '|nil)%)', '@return %1')
+ line = line:gsub('@return%s+.*%((' .. type .. '%?)%)', '@return %1')
+ end
+ return line
+end
+
+--- If we collected any `---` lines. Add them to the existing (or new) object
+--- Used for function/class descriptions and multiline param descriptions.
+--- @param state nvim.luacats.parser.State
+local function add_doc_lines_to_obj(state)
+ if state.doc_lines then
+ state.cur_obj = state.cur_obj or {}
+ local cur_obj = assert(state.cur_obj)
+ local txt = table.concat(state.doc_lines, '\n')
+ if cur_obj.desc then
+ cur_obj.desc = cur_obj.desc .. '\n' .. txt
+ else
+ cur_obj.desc = txt
+ end
+ state.doc_lines = nil
+ end
+end
+
+--- @param line string
+--- @param state nvim.luacats.parser.State
+local function process_doc_line(line, state)
+ line = line:sub(4):gsub('^%s+@', '@')
+ line = use_type_alt(line)
+
+ local parsed = luacats_grammar:match(line)
+
+ if not parsed then
+ if line:match('^ ') then
+ line = line:sub(2)
+ end
+
+ if state.last_doc_item then
+ if not state.last_doc_item_indent then
+ state.last_doc_item_indent = #line:match('^%s*') + 1
+ end
+ state.last_doc_item.desc = (state.last_doc_item.desc or '')
+ .. '\n'
+ .. line:sub(state.last_doc_item_indent or 1)
+ else
+ state.doc_lines = state.doc_lines or {}
+ table.insert(state.doc_lines, line)
+ end
+ return
+ end
+
+ state.last_doc_item_indent = nil
+ state.last_doc_item = nil
+ state.cur_obj = state.cur_obj or {}
+ local cur_obj = assert(state.cur_obj)
+
+ local kind = parsed.kind
+
+ if kind == 'brief' then
+ state.cur_obj = {
+ kind = 'brief',
+ desc = parsed.desc,
+ }
+ elseif kind == 'class' then
+ --- @cast parsed nvim.luacats.Class
+ cur_obj.kind = 'class'
+ cur_obj.name = parsed.name
+ cur_obj.parent = parsed.parent
+ cur_obj.access = parsed.access
+ cur_obj.desc = state.doc_lines and table.concat(state.doc_lines, '\n') or nil
+ state.doc_lines = nil
+ cur_obj.fields = {}
+ elseif kind == 'field' then
+ --- @cast parsed nvim.luacats.Field
+ parsed.desc = parsed.desc or state.doc_lines and table.concat(state.doc_lines, '\n') or nil
+ if parsed.desc then
+ parsed.desc = vim.trim(parsed.desc)
+ end
+ table.insert(cur_obj.fields, parsed)
+ state.doc_lines = nil
+ elseif kind == 'operator' then
+ parsed.desc = parsed.desc or state.doc_lines and table.concat(state.doc_lines, '\n') or nil
+ if parsed.desc then
+ parsed.desc = vim.trim(parsed.desc)
+ end
+ table.insert(cur_obj.fields, parsed)
+ state.doc_lines = nil
+ elseif kind == 'param' then
+ state.last_doc_item_indent = nil
+ cur_obj.params = cur_obj.params or {}
+ if vim.endswith(parsed.name, '?') then
+ parsed.name = parsed.name:sub(1, -2)
+ parsed.type = parsed.type .. '?'
+ end
+ state.last_doc_item = {
+ name = parsed.name,
+ type = parsed.type,
+ desc = parsed.desc,
+ }
+ table.insert(cur_obj.params, state.last_doc_item)
+ elseif kind == 'return' then
+ cur_obj.returns = cur_obj.returns or {}
+ for _, t in ipairs(parsed) do
+ table.insert(cur_obj.returns, {
+ name = t.name,
+ type = t.type,
+ desc = parsed.desc,
+ })
+ end
+ state.last_doc_item_indent = nil
+ state.last_doc_item = cur_obj.returns[#cur_obj.returns]
+ elseif kind == 'private' then
+ cur_obj.access = 'private'
+ elseif kind == 'package' then
+ cur_obj.access = 'package'
+ elseif kind == 'protected' then
+ cur_obj.access = 'protected'
+ elseif kind == 'deprecated' then
+ cur_obj.deprecated = true
+ elseif kind == 'inlinedoc' then
+ cur_obj.inlinedoc = true
+ elseif kind == 'nodoc' then
+ cur_obj.nodoc = true
+ elseif kind == 'since' then
+ cur_obj.since = parsed.desc
+ elseif kind == 'see' then
+ cur_obj.see = cur_obj.see or {}
+ table.insert(cur_obj.see, { desc = parsed.desc })
+ elseif kind == 'note' then
+ state.last_doc_item_indent = nil
+ state.last_doc_item = {
+ desc = parsed.desc,
+ }
+ cur_obj.notes = cur_obj.notes or {}
+ table.insert(cur_obj.notes, state.last_doc_item)
+ elseif kind == 'type' then
+ cur_obj.desc = parsed.desc
+ parsed.desc = nil
+ parsed.kind = nil
+ cur_obj.type = parsed
+ elseif kind == 'alias' then
+ state.cur_obj = {
+ kind = 'alias',
+ desc = parsed.desc,
+ }
+ elseif kind == 'enum' then
+ -- TODO
+ state.doc_lines = nil
+ elseif
+ vim.tbl_contains({
+ 'diagnostic',
+ 'cast',
+ 'overload',
+ 'meta',
+ }, kind)
+ then
+ -- Ignore
+ return
+ elseif kind == 'generic' then
+ cur_obj.generics = cur_obj.generics or {}
+ cur_obj.generics[parsed.name] = parsed.type or 'any'
+ else
+ error('Unhandled' .. vim.inspect(parsed))
+ end
+end
+
+--- @param fun nvim.luacats.parser.fun
+--- @return nvim.luacats.parser.field
+local function fun2field(fun)
+ local parts = { 'fun(' }
+
+ local params = {} ---@type string[]
+ for _, p in ipairs(fun.params or {}) do
+ params[#params + 1] = string.format('%s: %s', p.name, p.type)
+ end
+ parts[#parts + 1] = table.concat(params, ', ')
+ parts[#parts + 1] = ')'
+ if fun.returns then
+ parts[#parts + 1] = ': '
+ local tys = {} --- @type string[]
+ for _, p in ipairs(fun.returns) do
+ tys[#tys + 1] = p.type
+ end
+ parts[#parts + 1] = table.concat(tys, ', ')
+ end
+
+ return {
+ name = fun.name,
+ type = table.concat(parts, ''),
+ access = fun.access,
+ desc = fun.desc,
+ nodoc = fun.nodoc,
+ }
+end
+
+--- Function to normalize known form for declaring functions and normalize into a more standard
+--- form.
+--- @param line string
+--- @return string
+local function filter_decl(line)
+ -- M.fun = vim._memoize(function(...)
+ -- ->
+ -- function M.fun(...)
+ line = line:gsub('^local (.+) = memoize%([^,]+, function%((.*)%)$', 'local function %1(%2)')
+ line = line:gsub('^(.+) = memoize%([^,]+, function%((.*)%)$', 'function %1(%2)')
+ return line
+end
+
+--- @param line string
+--- @param state nvim.luacats.parser.State
+--- @param classes table<string,nvim.luacats.parser.class>
+--- @param classvars table<string,string>
+--- @param has_indent boolean
+local function process_lua_line(line, state, classes, classvars, has_indent)
+ line = filter_decl(line)
+
+ if state.cur_obj and state.cur_obj.kind == 'class' then
+ local nm = line:match('^local%s+([a-zA-Z0-9_]+)%s*=')
+ if nm then
+ classvars[nm] = state.cur_obj.name
+ end
+ return
+ end
+
+ do
+ local parent_tbl, sep, fun_or_meth_nm =
+ line:match('^function%s+([a-zA-Z0-9_]+)([.:])([a-zA-Z0-9_]+)%s*%(')
+ if parent_tbl then
+ -- Have a decl. Ensure cur_obj
+ state.cur_obj = state.cur_obj or {}
+ local cur_obj = assert(state.cur_obj)
+
+ -- Match `Class:foo` methods for defined classes
+ local class = classvars[parent_tbl]
+ if class then
+ --- @cast cur_obj nvim.luacats.parser.fun
+ cur_obj.name = fun_or_meth_nm
+ cur_obj.class = class
+ cur_obj.classvar = parent_tbl
+ -- Add self param to methods
+ if sep == ':' then
+ cur_obj.params = cur_obj.params or {}
+ table.insert(cur_obj.params, 1, {
+ name = 'self',
+ type = class,
+ })
+ end
+
+ -- Add method as the field to the class
+ local cls = classes[class]
+ local field = fun2field(cur_obj)
+ field.classvar = cur_obj.classvar
+ table.insert(cls.fields, field)
+ return
+ end
+
+ -- Match `M.foo`
+ if cur_obj and parent_tbl == cur_obj.modvar then
+ cur_obj.name = fun_or_meth_nm
+ return
+ end
+ end
+ end
+
+ do
+ -- Handle: `function A.B.C.foo(...)`
+ local fn_nm = line:match('^function%s+([.a-zA-Z0-9_]+)%s*%(')
+ if fn_nm then
+ state.cur_obj = state.cur_obj or {}
+ state.cur_obj.name = fn_nm
+ return
+ end
+ end
+
+ do
+ -- Handle: `M.foo = {...}` where `M` is the modvar
+ local parent_tbl, tbl_nm = line:match('([a-zA-Z_]+)%.([a-zA-Z0-9_]+)%s*=')
+ if state.cur_obj and parent_tbl and parent_tbl == state.cur_obj.modvar then
+ state.cur_obj.name = tbl_nm
+ state.cur_obj.table = true
+ return
+ end
+ end
+
+ do
+ -- Handle: `foo = {...}`
+ local tbl_nm = line:match('^([a-zA-Z0-9_]+)%s*=')
+ if tbl_nm and not has_indent then
+ state.cur_obj = state.cur_obj or {}
+ state.cur_obj.name = tbl_nm
+ state.cur_obj.table = true
+ return
+ end
+ end
+
+ do
+ -- Handle: `vim.foo = {...}`
+ local tbl_nm = line:match('^(vim%.[a-zA-Z0-9_]+)%s*=')
+ if state.cur_obj and tbl_nm and not has_indent then
+ state.cur_obj.name = tbl_nm
+ state.cur_obj.table = true
+ return
+ end
+ end
+
+ if state.cur_obj then
+ if line:find('^%s*%-%- luacheck:') then
+ state.cur_obj = nil
+ elseif line:find('^%s*local%s+') then
+ state.cur_obj = nil
+ elseif line:find('^%s*return%s+') then
+ state.cur_obj = nil
+ elseif line:find('^%s*[a-zA-Z_.]+%(%s+') then
+ state.cur_obj = nil
+ end
+ end
+end
+
+--- Determine the table name used to export functions of a module
+--- Usually this is `M`.
+--- @param str string
+--- @return string?
+local function determine_modvar(str)
+ local modvar --- @type string?
+ for line in vim.gsplit(str, '\n') do
+ do
+ --- @type string?
+ local m = line:match('^return%s+([a-zA-Z_]+)')
+ if m then
+ modvar = m
+ end
+ end
+ do
+ --- @type string?
+ local m = line:match('^return%s+setmetatable%(([a-zA-Z_]+),')
+ if m then
+ modvar = m
+ end
+ end
+ end
+ return modvar
+end
+
+--- @param obj nvim.luacats.parser.obj
+--- @param funs nvim.luacats.parser.fun[]
+--- @param classes table<string,nvim.luacats.parser.class>
+--- @param briefs string[]
+--- @param uncommitted nvim.luacats.parser.obj[]
+local function commit_obj(obj, classes, funs, briefs, uncommitted)
+ local commit = false
+ if obj.kind == 'class' then
+ --- @cast obj nvim.luacats.parser.class
+ if not classes[obj.name] then
+ classes[obj.name] = obj
+ commit = true
+ end
+ elseif obj.kind == 'alias' then
+ -- Just pretend
+ commit = true
+ elseif obj.kind == 'brief' then
+ --- @cast obj nvim.luacats.parser.brief`
+ briefs[#briefs + 1] = obj.desc
+ commit = true
+ else
+ --- @cast obj nvim.luacats.parser.fun`
+ if obj.name then
+ funs[#funs + 1] = obj
+ commit = true
+ end
+ end
+ if not commit then
+ table.insert(uncommitted, obj)
+ end
+ return commit
+end
+
+--- @param filename string
+--- @param uncommitted nvim.luacats.parser.obj[]
+-- luacheck: no unused
+local function dump_uncommitted(filename, uncommitted)
+ local out_path = 'luacats-uncommited/' .. filename:gsub('/', '%%') .. '.txt'
+ if #uncommitted > 0 then
+ print(string.format('Could not commit %d objects in %s', #uncommitted, filename))
+ vim.fn.mkdir(vim.fs.dirname(out_path), 'p')
+ local f = assert(io.open(out_path, 'w'))
+ for i, x in ipairs(uncommitted) do
+ f:write(i)
+ f:write(': ')
+ f:write(vim.inspect(x))
+ f:write('\n')
+ end
+ f:close()
+ else
+ vim.fn.delete(out_path)
+ end
+end
+
+local M = {}
+
+function M.parse_str(str, filename)
+ local funs = {} --- @type nvim.luacats.parser.fun[]
+ local classes = {} --- @type table<string,nvim.luacats.parser.class>
+ local briefs = {} --- @type string[]
+
+ local mod_return = determine_modvar(str)
+
+ --- @type string
+ local module = filename:match('.*/lua/([a-z_][a-z0-9_/]+)%.lua') or filename
+ module = module:gsub('/', '.')
+
+ local classvars = {} --- @type table<string,string>
+
+ local state = {} --- @type nvim.luacats.parser.State
+
+ -- Keep track of any partial objects we don't commit
+ local uncommitted = {} --- @type nvim.luacats.parser.obj[]
+
+ for line in vim.gsplit(str, '\n') do
+ local has_indent = line:match('^%s+') ~= nil
+ line = vim.trim(line)
+ if vim.startswith(line, '---') then
+ process_doc_line(line, state)
+ else
+ add_doc_lines_to_obj(state)
+
+ if state.cur_obj then
+ state.cur_obj.modvar = mod_return
+ state.cur_obj.module = module
+ end
+
+ process_lua_line(line, state, classes, classvars, has_indent)
+
+ -- Commit the object
+ local cur_obj = state.cur_obj
+ if cur_obj then
+ if not commit_obj(cur_obj, classes, funs, briefs, uncommitted) then
+ --- @diagnostic disable-next-line:inject-field
+ cur_obj.line = line
+ end
+ end
+
+ state = {}
+ end
+ end
+
+ -- dump_uncommitted(filename, uncommitted)
+
+ return classes, funs, briefs, uncommitted
+end
+
+--- @param filename string
+function M.parse(filename)
+ local f = assert(io.open(filename, 'r'))
+ local txt = f:read('*all')
+ f:close()
+
+ return M.parse_str(txt, filename)
+end
+
+return M
diff --git a/src/gen/nvim_version.lua.in b/src/gen/nvim_version.lua.in
new file mode 100644
index 0000000000..c29141fc68
--- /dev/null
+++ b/src/gen/nvim_version.lua.in
@@ -0,0 +1,9 @@
+return {
+ {"major", ${NVIM_VERSION_MAJOR}},
+ {"minor", ${NVIM_VERSION_MINOR}},
+ {"patch", ${NVIM_VERSION_PATCH}},
+ {"prerelease", "${NVIM_VERSION_PRERELEASE}" ~= ""},
+ {"api_level", ${NVIM_API_LEVEL}},
+ {"api_compatible", ${NVIM_API_LEVEL_COMPAT}},
+ {"api_prerelease", ${NVIM_API_PRERELEASE}},
+}
diff --git a/src/gen/preload.lua b/src/gen/preload.lua
new file mode 100644
index 0000000000..4856d8d7a1
--- /dev/null
+++ b/src/gen/preload.lua
@@ -0,0 +1,6 @@
+local srcdir = table.remove(arg, 1)
+
+package.path = (srcdir .. '/src/?.lua;') .. (srcdir .. '/runtime/lua/?.lua;') .. package.path
+
+arg[0] = table.remove(arg, 1)
+return loadfile(arg[0])()
diff --git a/src/gen/preload_nlua.lua b/src/gen/preload_nlua.lua
new file mode 100644
index 0000000000..a1d89105bc
--- /dev/null
+++ b/src/gen/preload_nlua.lua
@@ -0,0 +1,17 @@
+local srcdir = table.remove(arg, 1)
+local nlualib = table.remove(arg, 1)
+local gendir = table.remove(arg, 1)
+
+package.path = (srcdir .. '/src/?.lua;')
+ .. (srcdir .. '/runtime/lua/?.lua;')
+ .. (gendir .. '/?.lua;')
+ .. package.path
+
+_G.vim = require 'vim.shared'
+_G.vim.inspect = require 'vim.inspect'
+package.cpath = package.cpath .. ';' .. nlualib
+require 'nlua0'
+vim.NIL = vim.mpack.NIL -- WOW BOB WOW
+
+arg[0] = table.remove(arg, 1)
+return loadfile(arg[0])()
diff --git a/src/gen/util.lua b/src/gen/util.lua
new file mode 100644
index 0000000000..5940221abe
--- /dev/null
+++ b/src/gen/util.lua
@@ -0,0 +1,399 @@
+-- TODO(justinmk): move most of this to `vim.text`.
+
+local fmt = string.format
+
+--- @class nvim.util.MDNode
+--- @field [integer] nvim.util.MDNode
+--- @field type string
+--- @field text? string
+
+local INDENTATION = 4
+
+local NBSP = string.char(160)
+
+local M = {}
+
+local function contains(t, xs)
+ return vim.tbl_contains(xs, t)
+end
+
+-- Map of api_level:version, by inspection of:
+-- :lua= vim.mpack.decode(vim.fn.readfile('test/functional/fixtures/api_level_9.mpack','B')).version
+M.version_level = {
+ [13] = '0.11.0',
+ [12] = '0.10.0',
+ [11] = '0.9.0',
+ [10] = '0.8.0',
+ [9] = '0.7.0',
+ [8] = '0.6.0',
+ [7] = '0.5.0',
+ [6] = '0.4.0',
+ [5] = '0.3.2',
+ [4] = '0.3.0',
+ [3] = '0.2.1',
+ [2] = '0.2.0',
+ [1] = '0.1.0',
+}
+
+--- @param txt string
+--- @param srow integer
+--- @param scol integer
+--- @param erow? integer
+--- @param ecol? integer
+--- @return string
+local function slice_text(txt, srow, scol, erow, ecol)
+ local lines = vim.split(txt, '\n')
+
+ if srow == erow then
+ return lines[srow + 1]:sub(scol + 1, ecol)
+ end
+
+ if erow then
+ -- Trim the end
+ for _ = erow + 2, #lines do
+ table.remove(lines, #lines)
+ end
+ end
+
+ -- Trim the start
+ for _ = 1, srow do
+ table.remove(lines, 1)
+ end
+
+ lines[1] = lines[1]:sub(scol + 1)
+ lines[#lines] = lines[#lines]:sub(1, ecol)
+
+ return table.concat(lines, '\n')
+end
+
+--- @param text string
+--- @return nvim.util.MDNode
+local function parse_md_inline(text)
+ local parser = vim.treesitter.languagetree.new(text, 'markdown_inline')
+ local root = parser:parse(true)[1]:root()
+
+ --- @param node TSNode
+ --- @return nvim.util.MDNode?
+ local function extract(node)
+ local ntype = node:type()
+
+ if ntype:match('^%p$') then
+ return
+ end
+
+ --- @type table<any,any>
+ local ret = { type = ntype }
+ ret.text = vim.treesitter.get_node_text(node, text)
+
+ local row, col = 0, 0
+
+ for child, child_field in node:iter_children() do
+ local e = extract(child)
+ if e and ntype == 'inline' then
+ local srow, scol = child:start()
+ if (srow == row and scol > col) or srow > row then
+ local t = slice_text(ret.text, row, col, srow, scol)
+ if t and t ~= '' then
+ table.insert(ret, { type = 'text', j = true, text = t })
+ end
+ end
+ row, col = child:end_()
+ end
+
+ if child_field then
+ ret[child_field] = e
+ else
+ table.insert(ret, e)
+ end
+ end
+
+ if ntype == 'inline' and (row > 0 or col > 0) then
+ local t = slice_text(ret.text, row, col)
+ if t and t ~= '' then
+ table.insert(ret, { type = 'text', text = t })
+ end
+ end
+
+ return ret
+ end
+
+ return extract(root) or {}
+end
+
+--- @param text string
+--- @return nvim.util.MDNode
+local function parse_md(text)
+ local parser = vim.treesitter.languagetree.new(text, 'markdown', {
+ injections = { markdown = '' },
+ })
+
+ local root = parser:parse(true)[1]:root()
+
+ local EXCLUDE_TEXT_TYPE = {
+ list = true,
+ list_item = true,
+ section = true,
+ document = true,
+ fenced_code_block = true,
+ fenced_code_block_delimiter = true,
+ }
+
+ --- @param node TSNode
+ --- @return nvim.util.MDNode?
+ local function extract(node)
+ local ntype = node:type()
+
+ if ntype:match('^%p$') or contains(ntype, { 'block_continuation' }) then
+ return
+ end
+
+ --- @type table<any,any>
+ local ret = { type = ntype }
+
+ if not EXCLUDE_TEXT_TYPE[ntype] then
+ ret.text = vim.treesitter.get_node_text(node, text)
+ end
+
+ if ntype == 'inline' then
+ ret = parse_md_inline(ret.text)
+ end
+
+ for child, child_field in node:iter_children() do
+ local e = extract(child)
+ if child_field then
+ ret[child_field] = e
+ else
+ table.insert(ret, e)
+ end
+ end
+
+ return ret
+ end
+
+ return extract(root) or {}
+end
+
+--- Prefixes each line in `text`.
+---
+--- Does not wrap, not important for "meta" files? (You probably want md_to_vimdoc instead.)
+---
+--- @param text string
+--- @param prefix_ string
+function M.prefix_lines(prefix_, text)
+ local r = ''
+ for _, l in ipairs(vim.split(text, '\n', { plain = true })) do
+ r = r .. vim.trim(prefix_ .. l) .. '\n'
+ end
+ return r
+end
+
+--- @param x string
+--- @param start_indent integer
+--- @param indent integer
+--- @param text_width integer
+--- @return string
+function M.wrap(x, start_indent, indent, text_width)
+ local words = vim.split(vim.trim(x), '%s+')
+ local parts = { string.rep(' ', start_indent) } --- @type string[]
+ local count = indent
+
+ for i, w in ipairs(words) do
+ if count > indent and count + #w > text_width - 1 then
+ parts[#parts + 1] = '\n'
+ parts[#parts + 1] = string.rep(' ', indent)
+ count = indent
+ elseif i ~= 1 then
+ parts[#parts + 1] = ' '
+ count = count + 1
+ end
+ count = count + #w
+ parts[#parts + 1] = w
+ end
+
+ return (table.concat(parts):gsub('%s+\n', '\n'):gsub('\n+$', ''))
+end
+
+--- @param node nvim.util.MDNode
+--- @param start_indent integer
+--- @param indent integer
+--- @param text_width integer
+--- @param level integer
+--- @return string[]
+local function render_md(node, start_indent, indent, text_width, level, is_list)
+ local parts = {} --- @type string[]
+
+ -- For debugging
+ local add_tag = false
+ -- local add_tag = true
+
+ local ntype = node.type
+
+ if add_tag then
+ parts[#parts + 1] = '<' .. ntype .. '>'
+ end
+
+ if ntype == 'text' then
+ parts[#parts + 1] = node.text
+ elseif ntype == 'html_tag' then
+ error('html_tag: ' .. node.text)
+ elseif ntype == 'inline_link' then
+ vim.list_extend(parts, { '*', node[1].text, '*' })
+ elseif ntype == 'shortcut_link' then
+ if node[1].text:find('^<.*>$') then
+ parts[#parts + 1] = node[1].text
+ elseif node[1].text:find('^%d+$') then
+ vim.list_extend(parts, { '[', node[1].text, ']' })
+ else
+ vim.list_extend(parts, { '|', node[1].text, '|' })
+ end
+ elseif ntype == 'backslash_escape' then
+ parts[#parts + 1] = node.text
+ elseif ntype == 'emphasis' then
+ parts[#parts + 1] = node.text:sub(2, -2)
+ elseif ntype == 'code_span' then
+ vim.list_extend(parts, { '`', node.text:sub(2, -2):gsub(' ', NBSP), '`' })
+ elseif ntype == 'inline' then
+ if #node == 0 then
+ local text = assert(node.text)
+ parts[#parts + 1] = M.wrap(text, start_indent, indent, text_width)
+ else
+ for _, child in ipairs(node) do
+ vim.list_extend(parts, render_md(child, start_indent, indent, text_width, level + 1))
+ end
+ end
+ elseif ntype == 'paragraph' then
+ local pparts = {}
+ for _, child in ipairs(node) do
+ vim.list_extend(pparts, render_md(child, start_indent, indent, text_width, level + 1))
+ end
+ parts[#parts + 1] = M.wrap(table.concat(pparts), start_indent, indent, text_width)
+ parts[#parts + 1] = '\n'
+ elseif ntype == 'code_fence_content' then
+ local lines = vim.split(node.text:gsub('\n%s*$', ''), '\n')
+
+ local cindent = indent + INDENTATION
+ if level > 3 then
+ -- The tree-sitter markdown parser doesn't parse the code blocks indents
+ -- correctly in lists. Fudge it!
+ lines[1] = ' ' .. lines[1] -- ¯\_(ツ)_/¯
+ cindent = indent - level
+ local _, initial_indent = lines[1]:find('^%s*')
+ initial_indent = initial_indent + cindent
+ if initial_indent < indent then
+ cindent = indent - INDENTATION
+ end
+ end
+
+ for _, l in ipairs(lines) do
+ if #l > 0 then
+ parts[#parts + 1] = string.rep(' ', cindent)
+ parts[#parts + 1] = l
+ end
+ parts[#parts + 1] = '\n'
+ end
+ elseif ntype == 'fenced_code_block' then
+ parts[#parts + 1] = '>'
+ for _, child in ipairs(node) do
+ if child.type == 'info_string' then
+ parts[#parts + 1] = child.text
+ break
+ end
+ end
+ parts[#parts + 1] = '\n'
+ for _, child in ipairs(node) do
+ if child.type ~= 'info_string' then
+ vim.list_extend(parts, render_md(child, start_indent, indent, text_width, level + 1))
+ end
+ end
+ parts[#parts + 1] = '<\n'
+ elseif ntype == 'html_block' then
+ local text = node.text:gsub('^<pre>help', '')
+ text = text:gsub('</pre>%s*$', '')
+ parts[#parts + 1] = text
+ elseif ntype == 'list_marker_dot' then
+ parts[#parts + 1] = node.text
+ elseif contains(ntype, { 'list_marker_minus', 'list_marker_star' }) then
+ parts[#parts + 1] = '• '
+ elseif ntype == 'list_item' then
+ parts[#parts + 1] = string.rep(' ', indent)
+ local offset = node[1].type == 'list_marker_dot' and 3 or 2
+ for i, child in ipairs(node) do
+ local sindent = i <= 2 and 0 or (indent + offset)
+ vim.list_extend(
+ parts,
+ render_md(child, sindent, indent + offset, text_width, level + 1, true)
+ )
+ end
+ else
+ if node.text then
+ error(fmt('cannot render:\n%s', vim.inspect(node)))
+ end
+ for i, child in ipairs(node) do
+ local start_indent0 = i == 1 and start_indent or indent
+ vim.list_extend(
+ parts,
+ render_md(child, start_indent0, indent, text_width, level + 1, is_list)
+ )
+ if ntype ~= 'list' and i ~= #node then
+ if (node[i + 1] or {}).type ~= 'list' then
+ parts[#parts + 1] = '\n'
+ end
+ end
+ end
+ end
+
+ if add_tag then
+ parts[#parts + 1] = '</' .. ntype .. '>'
+ end
+
+ return parts
+end
+
+--- @param text_width integer
+local function align_tags(text_width)
+ --- @param line string
+ --- @return string
+ return function(line)
+ local tag_pat = '%s*(%*.+%*)%s*$'
+ local tags = {}
+ for m in line:gmatch(tag_pat) do
+ table.insert(tags, m)
+ end
+
+ if #tags > 0 then
+ line = line:gsub(tag_pat, '')
+ local tags_str = ' ' .. table.concat(tags, ' ')
+ --- @type integer
+ local conceal_offset = select(2, tags_str:gsub('%*', '')) - 2
+ local pad = string.rep(' ', text_width - #line - #tags_str + conceal_offset)
+ return line .. pad .. tags_str
+ end
+
+ return line
+ end
+end
+
+--- @param text string
+--- @param start_indent integer
+--- @param indent integer
+--- @param is_list? boolean
+--- @return string
+function M.md_to_vimdoc(text, start_indent, indent, text_width, is_list)
+ -- Add an extra newline so the parser can properly capture ending ```
+ local parsed = parse_md(text .. '\n')
+ local ret = render_md(parsed, start_indent, indent, text_width, 0, is_list)
+
+ local lines = vim.split(table.concat(ret):gsub(NBSP, ' '), '\n')
+
+ lines = vim.tbl_map(align_tags(text_width), lines)
+
+ local s = table.concat(lines, '\n')
+
+ -- Reduce whitespace in code-blocks
+ s = s:gsub('\n+%s*>([a-z]+)\n', ' >%1\n')
+ s = s:gsub('\n+%s*>\n?\n', ' >\n')
+
+ return s
+end
+
+return M