neovim/scripts/gen_eval_files.lua
Gregory Anders e0d97d264f
build: use built nvim artifact to generate eval files (#25875)
In cases where the generated files depend on changes to Nvim itself,
generating the files with an older version of Nvim will fail because
those changes are not present in the older version.

For example, if a new option is added then the generator script should
be run with the version of Nvim that contains the new option, or else
the generation will fail.

Co-authored-by: dundargoc <gocdundar@gmail.com>
2023-11-02 11:12:38 -07:00

814 lines
19 KiB
Lua
Executable File

-- Generator for various vimdoc and Lua type files
local DEP_API_METADATA = 'build/api_metadata.mpack'
local DEP_API_DOC = 'runtime/doc/api.mpack'
--- @class vim.api.metadata
--- @field name string
--- @field parameters {[1]:string,[2]: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_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')",
'',
'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_KEYWORDS = {
['and'] = true,
['end'] = true,
['function'] = true,
['or'] = true,
['if'] = true,
['while'] = true,
['repeat'] = true,
}
local OPTION_TYPES = {
bool = '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',
Dictionary = 'table<string,any>',
Float = 'number',
void = '',
}
--- @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)
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('^DictionaryOf%((.*)%)')
if d0 then
return 'table<string,' .. api_type(d0) .. '>'
end
return API_TYPES[t] or t
end
--- @param f string
--- @param params {[1]:string,[2]: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 {[1]:string,[2]:string}
--- @return string
function(v)
return v[1]
end,
params
),
', '
)
end
if LUA_KEYWORDS[f] then
return string.format("vim.fn['%s'] = function(%s) end", f, param_str)
else
return string.format('function vim.fn.%s(%s) end', f, param_str)
end
end
--- Uniquify names
--- Fix any names that are lua keywords
--- @param params {[1]:string,[2]:string,[3]:string}[]
--- @return {[1]:string,[2]:string,[3]:string}[]
local function process_params(params)
local seen = {} --- @type table<string,true>
local sfx = 1
for _, p in ipairs(params) do
if LUA_KEYWORDS[p[1]] then
p[1] = p[1] .. '_'
end
if seen[p[1]] then
p[1] = p[1] .. sfx
sfx = sfx + 1
else
seen[p[1]] = true
end
end
return params
end
--- @class vim.gen_vim_doc_fun
--- @field signature string
--- @field doc string[]
--- @field parameters_doc table<string,string>
--- @field return string[]
--- @field seealso string[]
--- @field annotations string[]
--- @return table<string, vim.EvalFn>
local function get_api_meta()
local mpack_f = assert(io.open(DEP_API_METADATA, 'rb'))
local metadata = vim.mpack.decode(mpack_f:read('*all')) --[[@as vim.api.metadata[] ]]
local ret = {} --- @type table<string, vim.EvalFn>
local doc_mpack_f = assert(io.open(DEP_API_DOC, 'rb'))
local doc_metadata = vim.mpack.decode(doc_mpack_f:read('*all')) --[[@as table<string,vim.gen_vim_doc_fun>]]
for _, fun in ipairs(metadata) do
local fdoc = doc_metadata[fun.name]
local params = {} --- @type {[1]:string,[2]:string}[]
for _, p in ipairs(fun.parameters) do
local ptype, pname = p[1], p[2]
params[#params + 1] = {
pname,
api_type(ptype),
fdoc and fdoc.parameters_doc[pname] or nil,
}
end
local r = {
signature = 'NA',
name = fun.name,
params = params,
returns = api_type(fun.return_type),
deprecated = fun.deprecated_since ~= nil,
}
if fdoc then
if #fdoc.doc > 0 then
r.desc = table.concat(fdoc.doc, '\n')
end
r.return_desc = (fdoc['return'] or {})[1]
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
--- @return string
local function norm_text(x)
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
--- @param _f string
--- @param fun vim.EvalFn
--- @param write fun(line: string)
local function render_api_meta(_f, fun, write)
if not vim.startswith(fun.name, 'nvim_') then
return
end
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
for _, l in ipairs(split(norm_text(desc))) do
write('--- ' .. l)
end
write('---')
end
local param_names = {} --- @type string[]
local params = process_params(fun.params)
for _, p in ipairs(params) do
param_names[#param_names + 1] = p[1]
local pdesc = p[3]
if pdesc then
local pdesc_a = split(norm_text(pdesc))
write('--- @param ' .. p[1] .. ' ' .. p[2] .. ' ' .. 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 ' .. p[1] .. ' ' .. p[2])
end
end
if fun.returns ~= '' then
if fun.returns_desc then
write('--- @return ' .. fun.returns .. ' : ' .. fun.returns_desc)
else
write('--- @return ' .. fun.returns)
end
end
local param_str = table.concat(param_names, ', ')
write(string.format('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'))
--- @diagnostic disable-next-line:no-unknown
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 params = {}
for _, key in ipairs(k.keys) do
table.insert(params, {key..'?', api_type(k.types[key] or 'any')})
end
ret[k.name] = {
signature = 'NA',
name = k.name,
params = params,
}
end
return ret
end
--- @param _f string
--- @param fun vim.EvalFn
--- @param write fun(line: string)
local function render_api_keyset_meta(_f, fun, write)
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('src/nvim/eval').funcs
end
--- @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)
if fun.signature then
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
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 = param[1], param[2]
local optional = (pname ~= '...' and i > req_args) and '?' or ''
write(string.format('--- @param %s%s %s', pname, optional, ptype))
end
if fun.returns ~= false then
write('--- @return ' .. (fun.returns or 'any'))
end
write(render_fun_sig(funname, params))
return
end
print('no doc for', funname)
end
--- @type table<string,true>
local rendered_tags = {}
--- @param name string
--- @param fun vim.EvalFn
--- @param write fun(line: string)
local function render_sig_and_tag(name, fun, write)
local tags = { '*' .. name .. '()*' }
if fun.tags then
for _, t in ipairs(fun.tags) do
tags[#tags + 1] = '*' .. t .. '*'
end
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(string.format('%s%s%s', fun.signature, string.rep(' ', tag_pad_len - siglen), tag))
end
end
--- @param f string
--- @param fun vim.EvalFn
--- @param write fun(line: string)
local function render_eval_doc(f, fun, write)
if fun.deprecated then
return
end
if not fun.signature then
return
end
local desc = fun.desc
if not desc then
write(fun.signature)
return
end
local name = fun.name or f
if rendered_tags[name] then
write(fun.signature)
else
render_sig_and_tag(name, fun, write)
rendered_tags[name] = true
end
desc = vim.trim(desc)
local desc_l = split(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
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
write('--- @type '..OPTION_TYPES[opt.type])
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', 'window'},
{'bo', 'buffer'},
{'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 s string[]
--- @return string
local function scope_to_doc(s)
local m = {
global = 'global',
buffer = 'local to buffer',
window = '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]]..' |global-local|'
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
--- @return table<string,vim.option_meta>
local function get_option_meta()
local opts = require('src/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
if 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
--- @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 == 'bool' 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 = string.format("'%s' '%s'", opt.full_name, opt.abbreviation)
else
name_str = string.format("'%s'", opt.full_name)
end
local otype = opt.type == 'bool' 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 = #string.format('%s%s%s (', name_str, pad, otype)
--- @type string
v = v:gsub('\n', '\n'..string.rep(' ', deflen - 2))
end
write(string.format('%s%s%s\t(default %s)', name_str, pad, otype, v))
else
write(string.format('%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
--- @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,
}
}
--- @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()