commit b45c50f3140e7ece593f2126840900f5cc3d39ea
parent f62728cd80a9c458b1c0ef7c5c1251e55fe91090
Author: Justin M. Keyes <justinkz@gmail.com>
Date: Fri, 4 Oct 2024 02:13:31 -0700
docs: render `@since` versions, 0 means experimental #30649
An implication of this current approach is that `NVIM_API_LEVEL` should be
bumped when a new Lua function is added.
TODO(future): add a lint check which requires `@since` on all new functions.
ref #25416
Diffstat:
17 files changed, 513 insertions(+), 390 deletions(-)
diff --git a/runtime/doc/develop.txt b/runtime/doc/develop.txt
@@ -247,6 +247,10 @@ Docstring format:
- References are written as `[tag]`
- Use ``` for code samples.
Code samples can be annotated as `vim` or `lua`
+- Use `@since <api-level>` to note the |api-level| when the function became
+ "stable". If `<api-level>` is greater than the current stable release (or
+ 0), it is marked as "experimental".
+ - See scripts/util.lua for the mapping of api-level to Nvim version.
- Use `@nodoc` to prevent documentation generation.
- Use `@inlinedoc` to inline `@class` blocks into `@param` blocks.
E.g. >lua
@@ -271,7 +275,7 @@ Docstring format:
- {somefield}? (integer) Documentation
for some field
<
-- Files which has `@meta` are only used for typing and documentation.
+- Files declared as `@meta` are only used for typing and documentation (similar to "*.d.ts" typescript files).
Example: the help for |vim.paste()| is generated from a docstring decorating
vim.paste in runtime/lua/vim/_editor.lua like this: >
@@ -288,6 +292,7 @@ vim.paste in runtime/lua/vim/_editor.lua like this: >
--- end)()
--- ```
---
+ --- @since 12
--- @see |paste|
---
--- @param lines ...
diff --git a/runtime/doc/diagnostic.txt b/runtime/doc/diagnostic.txt
@@ -733,6 +733,9 @@ hide({namespace}, {bufnr}) *vim.diagnostic.hide()*
is_enabled({filter}) *vim.diagnostic.is_enabled()*
Check whether diagnostics are enabled.
+ Attributes: ~
+ Since: 0.10.0
+
Parameters: ~
• {filter} (`table?`) Optional filters |kwargs|, or `nil` for all.
• {ns_id}? (`integer`) Diagnostic namespace, or `nil` for
diff --git a/runtime/doc/lsp.txt b/runtime/doc/lsp.txt
@@ -1673,6 +1673,9 @@ enable({enable}, {filter}) *vim.lsp.inlay_hint.enable()*
vim.lsp.inlay_hint.enable(not vim.lsp.inlay_hint.is_enabled())
<
+ Attributes: ~
+ Since: 0.10.0
+
Parameters: ~
• {enable} (`boolean?`) true/nil to enable, false to disable
• {filter} (`table?`) Optional filters |kwargs|, or `nil` for all.
@@ -1697,6 +1700,9 @@ get({filter}) *vim.lsp.inlay_hint.get()*
})
<
+ Attributes: ~
+ Since: 0.10.0
+
Parameters: ~
• {filter} (`table?`) Optional filters |kwargs|:
• {bufnr} (`integer?`)
@@ -1711,6 +1717,9 @@ get({filter}) *vim.lsp.inlay_hint.get()*
is_enabled({filter}) *vim.lsp.inlay_hint.is_enabled()*
Query whether inlay hint is enabled in the {filter}ed scope
+ Attributes: ~
+ Since: 0.10.0
+
Parameters: ~
• {filter} (`table?`) Optional filters |kwargs|, or `nil` for all.
• {bufnr} (`integer?`) Buffer number, or 0 for current
diff --git a/runtime/doc/lua.txt b/runtime/doc/lua.txt
@@ -1103,7 +1103,9 @@ vim.stricmp({a}, {b}) *vim.stricmp()*
lesser than {b}, respectively.
vim.ui_attach({ns}, {options}, {callback}) *vim.ui_attach()*
- Attach to ui events, similar to |nvim_ui_attach()| but receive events as
+ WARNING: This feature is experimental/unstable.
+
+ Attach to |ui-events|, similar to |nvim_ui_attach()| but receive events as
Lua callback. Can be used to implement screen elements like popupmenu or
message handling in Lua.
@@ -1856,6 +1858,9 @@ vim.inspect_pos({bufnr}, {row}, {col}, {filter}) *vim.inspect_pos()*
Can also be pretty-printed with `:Inspect!`. *:Inspect!*
+ Attributes: ~
+ Since: 0.9.0
+
Parameters: ~
• {bufnr} (`integer?`) defaults to the current buffer
• {row} (`integer?`) row to inspect, 0-based. Defaults to the row of
@@ -1889,6 +1894,9 @@ vim.show_pos({bufnr}, {row}, {col}, {filter}) *vim.show_pos()*
Can also be shown with `:Inspect`. *:Inspect*
+ Attributes: ~
+ Since: 0.9.0
+
Parameters: ~
• {bufnr} (`integer?`) defaults to the current buffer
• {row} (`integer?`) row to inspect, 0-based. Defaults to the row of
@@ -2461,11 +2469,15 @@ vim.validate({opt}) *vim.validate()*
Lua module: vim.loader *vim.loader*
vim.loader.disable() *vim.loader.disable()*
+ WARNING: This feature is experimental/unstable.
+
Disables the experimental Lua module loader:
• removes the loaders
• adds the default Nvim loader
vim.loader.enable() *vim.loader.enable()*
+ WARNING: This feature is experimental/unstable.
+
Enables the experimental Lua module loader:
• overrides loadfile
• adds the Lua loader using the byte-compilation cache
@@ -2473,6 +2485,8 @@ vim.loader.enable() *vim.loader.enable()*
• removes the default Nvim loader
vim.loader.find({modname}, {opts}) *vim.loader.find()*
+ WARNING: This feature is experimental/unstable.
+
Finds Lua modules for the given module name.
Parameters: ~
@@ -2498,6 +2512,8 @@ vim.loader.find({modname}, {opts}) *vim.loader.find()*
returned for `modname="*"`
vim.loader.reset({path}) *vim.loader.reset()*
+ WARNING: This feature is experimental/unstable.
+
Resets the cache for the path, or all the paths if path is nil.
Parameters: ~
@@ -2767,6 +2783,9 @@ vim.filetype.get_option({filetype}, {option})
means |ftplugin| and |FileType| autocommands are only triggered once and
may not reflect later changes.
+ Attributes: ~
+ Since: 0.9.0
+
Parameters: ~
• {filetype} (`string`) Filetype
• {option} (`string`) Option name
@@ -3649,6 +3668,9 @@ vim.secure.read({path}) *vim.secure.read()*
be trusted. The user's choice is persisted in a trust database at
$XDG_STATE_HOME/nvim/trust.
+ Attributes: ~
+ Since: 0.9.0
+
Parameters: ~
• {path} (`string`) Path to a file to read.
@@ -3664,6 +3686,9 @@ vim.secure.trust({opts}) *vim.secure.trust()*
The trust database is located at |$XDG_STATE_HOME|/nvim/trust.
+ Attributes: ~
+ Since: 0.9.0
+
Parameters: ~
• {opts} (`table`) A table with the following fields:
• {action} (`'allow'|'deny'|'remove'`) - `'allow'` to add a
@@ -3755,6 +3780,9 @@ vim.version.cmp({v1}, {v2}) *vim.version.cmp()*
• Per semver, build metadata is ignored when comparing two
otherwise-equivalent versions.
+ Attributes: ~
+ Since: 0.9.0
+
Parameters: ~
• {v1} (`vim.Version|number[]|string`) Version object.
• {v2} (`vim.Version|number[]|string`) Version to compare with `v1`.
@@ -3766,6 +3794,9 @@ vim.version.eq({v1}, {v2}) *vim.version.eq()*
Returns `true` if the given versions are equal. See |vim.version.cmp()|
for usage.
+ Attributes: ~
+ Since: 0.9.0
+
Parameters: ~
• {v1} (`vim.Version|number[]|string`)
• {v2} (`vim.Version|number[]|string`)
@@ -3776,6 +3807,9 @@ vim.version.eq({v1}, {v2}) *vim.version.eq()*
vim.version.ge({v1}, {v2}) *vim.version.ge()*
Returns `true` if `v1 >= v2`. See |vim.version.cmp()| for usage.
+ Attributes: ~
+ Since: 0.10.0
+
Parameters: ~
• {v1} (`vim.Version|number[]|string`)
• {v2} (`vim.Version|number[]|string`)
@@ -3786,6 +3820,9 @@ vim.version.ge({v1}, {v2}) *vim.version.ge()*
vim.version.gt({v1}, {v2}) *vim.version.gt()*
Returns `true` if `v1 > v2`. See |vim.version.cmp()| for usage.
+ Attributes: ~
+ Since: 0.9.0
+
Parameters: ~
• {v1} (`vim.Version|number[]|string`)
• {v2} (`vim.Version|number[]|string`)
@@ -3805,6 +3842,9 @@ vim.version.last({versions}) *vim.version.last()*
vim.version.le({v1}, {v2}) *vim.version.le()*
Returns `true` if `v1 <= v2`. See |vim.version.cmp()| for usage.
+ Attributes: ~
+ Since: 0.10.0
+
Parameters: ~
• {v1} (`vim.Version|number[]|string`)
• {v2} (`vim.Version|number[]|string`)
@@ -3815,6 +3855,9 @@ vim.version.le({v1}, {v2}) *vim.version.le()*
vim.version.lt({v1}, {v2}) *vim.version.lt()*
Returns `true` if `v1 < v2`. See |vim.version.cmp()| for usage.
+ Attributes: ~
+ Since: 0.9.0
+
Parameters: ~
• {v1} (`vim.Version|number[]|string`)
• {v2} (`vim.Version|number[]|string`)
@@ -3829,6 +3872,9 @@ vim.version.parse({version}, {opts}) *vim.version.parse()*
{ major = 1, minor = 0, patch = 1, prerelease = "rc1", build = "build.2" }
<
+ Attributes: ~
+ Since: 0.9.0
+
Parameters: ~
• {version} (`string`) Version string to parse.
• {opts} (`table?`) Optional keyword arguments:
@@ -3869,6 +3915,9 @@ vim.version.range({spec}) *vim.version.range()*
print(vim.version.ge({1,0,3}, r.from) and vim.version.lt({1,0,3}, r.to))
<
+ Attributes: ~
+ Since: 0.9.0
+
Parameters: ~
• {spec} (`string`) Version range "spec"
diff --git a/runtime/doc/treesitter.txt b/runtime/doc/treesitter.txt
@@ -854,6 +854,9 @@ foldexpr({lnum}) *vim.treesitter.foldexpr()*
vim.wo.foldexpr = 'v:lua.vim.treesitter.foldexpr()'
<
+ Attributes: ~
+ Since: 0.9.0
+
Parameters: ~
• {lnum} (`integer?`) Line number to calculate fold level for
@@ -1003,6 +1006,9 @@ inspect_tree({opts}) *vim.treesitter.inspect_tree()*
Can also be shown with `:InspectTree`. *:InspectTree*
+ Attributes: ~
+ Since: 0.9.0
+
Parameters: ~
• {opts} (`table?`) Optional options table with the following possible
keys:
diff --git a/runtime/lua/vim/_inspector.lua b/runtime/lua/vim/_inspector.lua
@@ -27,6 +27,7 @@ local defaults = {
---
---Can also be pretty-printed with `:Inspect!`. [:Inspect!]()
---
+---@since 11
---@param bufnr? integer defaults to the current buffer
---@param row? integer row to inspect, 0-based. Defaults to the row of the current cursor
---@param col? integer col to inspect, 0-based. Defaults to the col of the current cursor
@@ -145,6 +146,7 @@ end
---
---Can also be shown with `:Inspect`. [:Inspect]()
---
+---@since 11
---@param bufnr? integer defaults to the current buffer
---@param row? integer row to inspect, 0-based. Defaults to the row of the current cursor
---@param col? integer col to inspect, 0-based. Defaults to the col of the current cursor
diff --git a/runtime/lua/vim/_meta/builtin.lua b/runtime/lua/vim/_meta/builtin.lua
@@ -248,7 +248,7 @@ function vim.schedule(fn) end
--- - If {callback} errors, the error is raised.
function vim.wait(time, callback, interval, fast_only) end
---- Attach to ui events, similar to |nvim_ui_attach()| but receive events
+--- Attach to |ui-events|, similar to |nvim_ui_attach()| but receive events
--- as Lua callback. Can be used to implement screen elements like
--- popupmenu or message handling in Lua.
---
@@ -282,6 +282,8 @@ function vim.wait(time, callback, interval, fast_only) end
--- end)
--- ```
---
+--- @since 0
+---
--- @param ns integer
--- @param options table<string, any>
--- @param callback fun()
diff --git a/runtime/lua/vim/filetype.lua b/runtime/lua/vim/filetype.lua
@@ -2839,6 +2839,7 @@ end
--- Note: this uses |nvim_get_option_value()| but caches the result.
--- This means |ftplugin| and |FileType| autocommands are only
--- triggered once and may not reflect later changes.
+--- @since 11
--- @param filetype string Filetype
--- @param option string Option name
--- @return string|boolean|integer: Option value
diff --git a/runtime/lua/vim/loader.lua b/runtime/lua/vim/loader.lua
@@ -289,6 +289,9 @@ function Loader.load(modpath, opts)
end
--- Finds Lua modules for the given module name.
+---
+--- @since 0
+---
---@param modname string Module name, or `"*"` to find the top-level modules instead
---@param opts? vim.loader.find.Opts Options for finding a module:
---@return vim.loader.ModuleInfo[]
@@ -377,8 +380,10 @@ function M.find(modname, opts)
return results
end
---- Resets the cache for the path, or all the paths
---- if path is nil.
+--- Resets the cache for the path, or all the paths if path is nil.
+---
+--- @since 0
+---
---@param path string? path to reset
function M.reset(path)
if path then
@@ -398,6 +403,8 @@ end
--- * adds the Lua loader using the byte-compilation cache
--- * adds the libs loader
--- * removes the default Nvim loader
+---
+--- @since 0
function M.enable()
if M.enabled then
return
@@ -421,6 +428,8 @@ end
--- Disables the experimental Lua module loader:
--- * removes the loaders
--- * adds the default Nvim loader
+---
+--- @since 0
function M.disable()
if not M.enabled then
return
diff --git a/runtime/lua/vim/secure.lua b/runtime/lua/vim/secure.lua
@@ -41,6 +41,7 @@ end
--- trusted. The user's choice is persisted in a trust database at
--- $XDG_STATE_HOME/nvim/trust.
---
+---@since 11
---@see |:trust|
---
---@param path (string) Path to a file to read.
@@ -126,6 +127,7 @@ end
---
--- The trust database is located at |$XDG_STATE_HOME|/nvim/trust.
---
+---@since 11
---@param opts vim.trust.opts
---@return boolean success true if operation was successful
---@return string msg full path if operation was successful, else error message
diff --git a/runtime/lua/vim/treesitter.lua b/runtime/lua/vim/treesitter.lua
@@ -447,6 +447,7 @@ end
---
--- Can also be shown with `:InspectTree`. [:InspectTree]()
---
+---@since 11
---@param opts table|nil Optional options table with the following possible keys:
--- - lang (string|nil): The language of the source buffer. If omitted, detect
--- from the filetype of the source buffer.
@@ -470,6 +471,7 @@ end
--- vim.wo.foldexpr = 'v:lua.vim.treesitter.foldexpr()'
--- ```
---
+---@since 11
---@param lnum integer|nil Line number to calculate fold level for
---@return string
function M.foldexpr(lnum)
diff --git a/runtime/lua/vim/version.lua b/runtime/lua/vim/version.lua
@@ -276,6 +276,7 @@ end
--- ```
---
--- @see # https://github.com/npm/node-semver#ranges
+--- @since 11
---
--- @param spec string Version range "spec"
--- @return vim.VersionRange?
@@ -375,6 +376,7 @@ end
--- ```
---
--- @note Per semver, build metadata is ignored when comparing two otherwise-equivalent versions.
+--- @since 11
---
---@param v1 vim.Version|number[]|string Version object.
---@param v2 vim.Version|number[]|string Version to compare with `v1`.
@@ -392,6 +394,7 @@ function M.cmp(v1, v2)
end
---Returns `true` if the given versions are equal. See |vim.version.cmp()| for usage.
+---@since 11
---@param v1 vim.Version|number[]|string
---@param v2 vim.Version|number[]|string
---@return boolean
@@ -400,6 +403,7 @@ function M.eq(v1, v2)
end
---Returns `true` if `v1 <= v2`. See |vim.version.cmp()| for usage.
+---@since 12
---@param v1 vim.Version|number[]|string
---@param v2 vim.Version|number[]|string
---@return boolean
@@ -408,6 +412,7 @@ function M.le(v1, v2)
end
---Returns `true` if `v1 < v2`. See |vim.version.cmp()| for usage.
+---@since 11
---@param v1 vim.Version|number[]|string
---@param v2 vim.Version|number[]|string
---@return boolean
@@ -416,6 +421,7 @@ function M.lt(v1, v2)
end
---Returns `true` if `v1 >= v2`. See |vim.version.cmp()| for usage.
+---@since 12
---@param v1 vim.Version|number[]|string
---@param v2 vim.Version|number[]|string
---@return boolean
@@ -424,6 +430,7 @@ function M.ge(v1, v2)
end
---Returns `true` if `v1 > v2`. See |vim.version.cmp()| for usage.
+---@since 11
---@param v1 vim.Version|number[]|string
---@param v2 vim.Version|number[]|string
---@return boolean
@@ -438,7 +445,8 @@ end
--- { major = 1, minor = 0, patch = 1, prerelease = "rc1", build = "build.2" }
--- ```
---
---- @see # https://semver.org/spec/v2.0.0.html
+---@see # https://semver.org/spec/v2.0.0.html
+---@since 11
---
---@param version string Version string to parse.
---@param opts table|nil Optional keyword arguments:
diff --git a/scripts/gen_eval_files.lua b/scripts/gen_eval_files.lua
@@ -309,7 +309,7 @@ end
local function render_api_meta(_f, fun, write)
write('')
- local text_utils = require('scripts.text_utils')
+ local util = require('scripts.util')
if vim.startswith(fun.name, 'nvim__') then
write('--- @private')
@@ -321,7 +321,7 @@ local function render_api_meta(_f, fun, write)
local desc = fun.desc
if desc then
- desc = text_utils.md_to_vimdoc(desc, 0, 0, 74)
+ desc = util.md_to_vimdoc(desc, 0, 0, 74)
for _, l in ipairs(split(norm_text(desc))) do
write('--- ' .. l)
end
@@ -332,7 +332,7 @@ local function render_api_meta(_f, fun, write)
write('--- Note:')
for _, note in ipairs(fun.notes) do
-- XXX: abuse md_to_vimdoc() to force-fit the markdown list. Need norm_md()?
- note = text_utils.md_to_vimdoc(' - ' .. note, 0, 0, 74)
+ note = util.md_to_vimdoc(' - ' .. note, 0, 0, 74)
for _, l in ipairs(split(vim.trim(norm_text(note)))) do
write('--- ' .. l:gsub('\n*$', ''))
end
@@ -341,7 +341,7 @@ local function render_api_meta(_f, fun, write)
end
for _, see in ipairs(fun.see or {}) do
- see = text_utils.md_to_vimdoc('@see ' .. see, 0, 0, 74)
+ see = util.md_to_vimdoc('@see ' .. see, 0, 0, 74)
for _, l in ipairs(split(vim.trim(norm_text(see)))) do
write('--- ' .. l:gsub([[\s*$]], ''))
end
@@ -355,7 +355,7 @@ local function render_api_meta(_f, fun, write)
if pdesc then
local s = '--- @param ' .. p[1] .. ' ' .. p[2] .. ' '
local indent = #('@param ' .. p[1] .. ' ')
- pdesc = text_utils.md_to_vimdoc(pdesc, #s, indent, 74, true)
+ pdesc = util.md_to_vimdoc(pdesc, #s, indent, 74, true)
local pdesc_a = split(vim.trim(norm_text(pdesc)))
write(s .. pdesc_a[1])
for i = 2, #pdesc_a do
@@ -371,7 +371,7 @@ local function render_api_meta(_f, fun, write)
if fun.returns ~= '' then
local ret_desc = fun.returns_desc and ' : ' .. fun.returns_desc or ''
- ret_desc = text_utils.md_to_vimdoc(ret_desc, 0, 0, 74)
+ ret_desc = util.md_to_vimdoc(ret_desc, 0, 0, 74)
local ret = LUA_API_RETURN_OVERRIDES[fun.name] or fun.returns
write('--- @return ' .. ret .. ret_desc)
end
diff --git a/scripts/gen_vimdoc.lua b/scripts/gen_vimdoc.lua
@@ -18,12 +18,12 @@
local luacats_parser = require('scripts.luacats_parser')
local cdoc_parser = require('scripts.cdoc_parser')
-local text_utils = require('scripts.text_utils')
+local util = require('scripts.util')
local fmt = string.format
-local wrap = text_utils.wrap
-local md_to_vimdoc = text_utils.md_to_vimdoc
+local wrap = util.wrap
+local md_to_vimdoc = util.md_to_vimdoc
local TEXT_WIDTH = 78
local INDENTATION = 4
@@ -730,19 +730,25 @@ local function render_fun(fun, classes, cfg)
table.insert(ret, render_fun_header(fun, cfg))
table.insert(ret, '\n')
- if fun.desc then
- table.insert(ret, md_to_vimdoc(fun.desc, INDENTATION, INDENTATION, TEXT_WIDTH))
- end
-
if fun.since then
- local since = tonumber(fun.since)
+ local since = assert(tonumber(fun.since), 'invalid @since on ' .. fun.name)
local info = nvim_api_info()
- if since and (since > info.level or since == info.level and info.prerelease) then
- fun.notes = fun.notes or {}
- table.insert(fun.notes, { desc = 'This API is pre-release (unstable).' })
+ 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
diff --git a/scripts/text_utils.lua b/scripts/text_utils.lua
@@ -1,365 +0,0 @@
-local fmt = string.format
-
---- @class nvim.text_utils.MDNode
---- @field [integer] nvim.text_utils.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
-
---- @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.text_utils.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.text_utils.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.text_utils.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.text_utils.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
-
---- @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.text_utils.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
diff --git a/scripts/util.lua b/scripts/util.lua
@@ -0,0 +1,384 @@
+-- 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 = {
+ [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
+
+--- @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
diff --git a/test/functional/script/text_utils_spec.lua b/test/functional/script/text_utils_spec.lua
@@ -11,8 +11,8 @@ local function md_to_vimdoc(text, start_indent, indent, text_width)
start_indent = start_indent or 0
indent = indent or 0
text_width = text_width or 70
- local text_utils = require('scripts/text_utils')
- return text_utils.md_to_vimdoc(table.concat(text, '\n'), start_indent, indent, text_width)
+ local util = require('scripts/util')
+ return util.md_to_vimdoc(table.concat(text, '\n'), start_indent, indent, text_width)
]],
text,
start_indent,