feat(lua): vim.text.indent()

Problem:
Indenting text is a common task in plugins/scripts for
presentation/formatting, yet vim has no way of doing it (especially
"dedent", and especially non-buffer text).

Solution:
Introduce `vim.text.indent()`. It sets the *exact* indentation because
that's a more difficult (and thus more useful) task than merely
"increasing the current indent" (which is somewhat easy with a `gsub()`
one-liner).
This commit is contained in:
Justin M. Keyes
2025-02-21 02:02:32 +01:00
parent f4921e2b7d
commit be1fbe38b3
31 changed files with 533 additions and 331 deletions

View File

@ -4624,6 +4624,41 @@ vim.text.hexencode({str}) *vim.text.hexencode()*
Return: ~
(`string`) Hex encoded string
vim.text.indent({size}, {text}, {opts}) *vim.text.indent()*
Sets the indent (i.e. the common leading whitespace) of non-empty lines in
`text` to `size` spaces/tabs.
Indent is calculated by number of consecutive indent chars.
• The first indented, non-empty line decides the indent char (space/tab):
• `SPC SPC TAB …` = two-space indent.
• `TAB SPC …` = one-tab indent.
• Set `opts.expandtab` to treat tabs as spaces.
To "dedent" (remove the common indent), pass `size=0`: >lua
vim.print(vim.text.indent(0, ' a\n b\n'))
<
To adjust relative-to an existing indent, call indent() twice: >lua
local indented, old_indent = vim.text.indent(0, ' a\n b\n')
indented = vim.text.indent(old_indent + 2, indented)
vim.print(indented)
<
To ignore the final, blank line when calculating the indent, use gsub()
before calling indent(): >lua
local text = ' a\n b\n '
vim.print(vim.text.indent(0, (text:gsub('\n[\t ]+\n?$', '\n'))))
<
Parameters: ~
• {size} (`integer`) Number of spaces.
• {text} (`string`) Text to indent.
• {opts} (`{ expandtab?: number }?`)
Return (multiple): ~
(`string`) Indented text.
(`integer`) Indent size before modification.
==============================================================================
Lua module: tohtml *vim.tohtml*

View File

@ -321,6 +321,7 @@ LUA
• |vim.fs.relpath()| gets relative path compared to base path.
• |vim.fs.dir()| and |vim.fs.find()| now follow symbolic links by default,
the behavior can be turn off using the new `follow` option.
• |vim.text.indent()| indents/dedents text.
OPTIONS

View File

@ -186,18 +186,13 @@ local function get_healthcheck(plugin_names)
return healthchecks
end
--- Indents lines *except* line 1 of a string if it contains newlines.
--- Indents lines *except* line 1 of a multiline string.
---
--- @param s string
--- @param columns integer
--- @return string
local function indent_after_line1(s, columns)
local lines = vim.split(s, '\n')
local indent = string.rep(' ', columns)
for i = 2, #lines do
lines[i] = indent .. lines[i]
end
return table.concat(lines, '\n')
return (vim.text.indent(columns, s):gsub('^%s+', ''))
end
--- Changes ':h clipboard' to ':help |clipboard|'.

View File

@ -50,4 +50,91 @@ function M.hexdecode(enc)
return table.concat(str), nil
end
--- Sets the indent (i.e. the common leading whitespace) of non-empty lines in `text` to `size`
--- spaces/tabs.
---
--- Indent is calculated by number of consecutive indent chars.
--- - The first indented, non-empty line decides the indent char (space/tab):
--- - `SPC SPC TAB …` = two-space indent.
--- - `TAB SPC …` = one-tab indent.
--- - Set `opts.expandtab` to treat tabs as spaces.
---
--- To "dedent" (remove the common indent), pass `size=0`:
--- ```lua
--- vim.print(vim.text.indent(0, ' a\n b\n'))
--- ```
---
--- To adjust relative-to an existing indent, call indent() twice:
--- ```lua
--- local indented, old_indent = vim.text.indent(0, ' a\n b\n')
--- indented = vim.text.indent(old_indent + 2, indented)
--- vim.print(indented)
--- ```
---
--- To ignore the final, blank line when calculating the indent, use gsub() before calling indent():
--- ```lua
--- local text = ' a\n b\n '
--- vim.print(vim.text.indent(0, (text:gsub('\n[\t ]+\n?$', '\n'))))
--- ```
---
--- @param size integer Number of spaces.
--- @param text string Text to indent.
--- @param opts? { expandtab?: number }
--- @return string # Indented text.
--- @return integer # Indent size _before_ modification.
function M.indent(size, text, opts)
vim.validate('size', size, 'number')
vim.validate('text', text, 'string')
vim.validate('opts', opts, 'table', true)
-- TODO(justinmk): `opts.prefix`, `predicate` like python https://docs.python.org/3/library/textwrap.html
opts = opts or {}
local tabspaces = opts.expandtab and (' '):rep(opts.expandtab) or nil
--- Minimum common indent shared by all lines.
local old_indent --[[@type number?]]
local prefix = tabspaces and ' ' or nil -- Indent char (space or tab).
--- Check all non-empty lines, capturing leading whitespace (if any).
--- @diagnostic disable-next-line: no-unknown
for line_ws, extra in text:gmatch('([\t ]*)([^\n]+)') do
line_ws = tabspaces and line_ws:gsub('[\t]', tabspaces) or line_ws
-- XXX: blank line will miss the last whitespace char in `line_ws`, so we need to check `extra`.
line_ws = line_ws .. (extra:match('^%s+$') or '')
if 0 == #line_ws then
-- Optimization: If any non-empty line has indent=0, there is no common indent.
old_indent = 0
break
end
prefix = prefix and prefix or line_ws:sub(1, 1)
local _, end_ = line_ws:find('^[' .. prefix .. ']+')
old_indent = math.min(old_indent or math.huge, end_ or 0)
end
-- Default to 0 if all lines are empty.
old_indent = old_indent or 0
prefix = prefix and prefix or ' '
if old_indent == size then
-- Optimization: if the indent is the same, return the text unchanged.
return text, old_indent
end
local new_indent = prefix:rep(size)
--- Replaces indentation of a line.
--- @param line string
local function replace_line(line)
-- Match the existing indent exactly; avoid over-matching any following whitespace.
local pat = prefix:rep(old_indent)
-- Expand tabs before replacing indentation.
line = not tabspaces and line
or line:gsub('^[\t ]+', function(s)
return s:gsub('\t', tabspaces)
end)
-- Text following the indent.
local line_text = line:match('^' .. pat .. '(.*)') or line
return new_indent .. line_text
end
return (text:gsub('[^\n]+', replace_line)), old_indent
end
return M

View File

@ -766,18 +766,8 @@ local function scope_more_doc(o)
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')
return (vim.text.indent(0, (x:gsub('\n%s-([\n]?)$', '\n%1'))))
end
--- @return table<string,vim.option_meta>

View File

@ -148,10 +148,6 @@ local function url_encode(s)
)
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
@ -275,25 +271,13 @@ end
---
--- 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
local _, indent = vim.text.indent(0, s, { expandtab = 8 })
return indent
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)
return vim.text.indent(0, s, { expandtab = 8 })
end
--- Gets raw buffer text in the node's range (+/- an offset), as a newline-delimited string.

View File

@ -342,6 +342,7 @@ set(LUA_KEYMAP_MODULE_SOURCE ${NVIM_RUNTIME_DIR}/lua/vim/keymap.lua)
set(LUA_LOADER_MODULE_SOURCE ${NVIM_RUNTIME_DIR}/lua/vim/loader.lua)
set(LUA_OPTIONS_MODULE_SOURCE ${NVIM_RUNTIME_DIR}/lua/vim/_options.lua)
set(LUA_SHARED_MODULE_SOURCE ${NVIM_RUNTIME_DIR}/lua/vim/shared.lua)
set(LUA_TEXT_MODULE_SOURCE ${NVIM_RUNTIME_DIR}/lua/vim/text.lua)
file(GLOB API_HEADERS CONFIGURE_DEPENDS api/*.h)
list(REMOVE_ITEM API_HEADERS ${CMAKE_CURRENT_LIST_DIR}/api/ui_events.in.h)
@ -624,6 +625,7 @@ add_custom_command(
${LUA_DEFAULTS_MODULE_SOURCE} "vim._defaults"
${LUA_OPTIONS_MODULE_SOURCE} "vim._options"
${LUA_SHARED_MODULE_SOURCE} "vim.shared"
${LUA_TEXT_MODULE_SOURCE} "vim.text"
DEPENDS
${CHAR_BLOB_GENERATOR}
${LUA_INIT_PACKAGES_MODULE_SOURCE}
@ -637,6 +639,7 @@ add_custom_command(
${LUA_DEFAULTS_MODULE_SOURCE}
${LUA_OPTIONS_MODULE_SOURCE}
${LUA_SHARED_MODULE_SOURCE}
${LUA_TEXT_MODULE_SOURCE}
VERBATIM
)

View File

@ -500,6 +500,8 @@ a]],
keep the lines under this line folded
keep this line folded 1
keep this line folded 2
.
]])
command('set foldmethod=indent shiftwidth=2 noautoindent')
eq(1, fn.foldlevel(1))

View File

@ -194,8 +194,7 @@ pcall(vim.cmd.edit, 'Xtest_swapredraw.lua')
{100:vim.o.foldexpr} {100:=} {101:'v:lua.vim.treesitter.foldexpr()'} |
{102:+-- 3 lines: vim.defer_fn(function()·······························································}|
{104:pcall}{100:(vim.cmd.edit,} {101:'Xtest_swapredraw.lua'}{100:)} |
|
{105:~ }|*33
{105:~ }|*34
{106:Xtest_swapredraw.lua 1,1 All}|
|
]])
@ -589,8 +588,8 @@ describe('quitting swapfile dialog on startup stops TUI properly', function()
api.nvim_chan_send(chan, 'q')
retry(nil, nil, function()
eq(
{ '', '[Process exited 1]', '' },
eval("[1, 2, '$']->map({_, lnum -> getline(lnum)->trim(' ', 2)})")
{ '[Process exited 1]' },
eval("[1, 2, '$']->map({_, lnum -> getline(lnum)->trim(' ', 2)})->filter({_, s -> !empty(trim(s))})")
)
end)
end)

View File

@ -6,6 +6,7 @@ local n = require('test.functional.testnvim')()
local clear, command, expect = n.clear, n.command, n.expect
local source, write_file = n.source, t.write_file
--- @return string
local function sixlines(text)
local result = ''
for _ = 1, 6 do
@ -16,6 +17,9 @@ end
local function diff(text, nodedent)
local fname = t.tmpname()
finally(function()
os.remove(fname)
end)
command('w! ' .. fname)
n.poke_eventloop()
local data = io.open(fname):read('*all')
@ -24,7 +28,6 @@ local function diff(text, nodedent)
else
t.eq(t.dedent(text), data)
end
os.remove(fname)
end
describe('character classes in regexp', function()
@ -38,7 +41,7 @@ describe('character classes in regexp', function()
local punct4 = '{|}~'
local ctrl2 = '\127\128\130\144\155'
local iso_text = '\166\177\188\199\211\233' -- "¦±¼ÇÓé" in utf-8
setup(function()
local function do_setup(no_dedent)
-- The original test32.in file was not in utf-8 encoding and did also
-- contain some control characters. We use lua escape sequences to write
-- them to the test file.
@ -52,8 +55,9 @@ describe('character classes in regexp', function()
.. punct4
.. ctrl2
.. iso_text
write_file('test36.in', sixlines(line))
end)
write_file('test36.in', sixlines(line), no_dedent)
end
setup(do_setup)
before_each(function()
clear()
command('e test36.in')
@ -288,7 +292,10 @@ describe('character classes in regexp', function()
ABCDEFGHIXYZ
ABCDEFGHIXYZ]])
end)
it([["\%1l^#.*" does not match on a line starting with "#". (vim-patch:7.4.1305)]], function()
pending(
[["\%1l^#.*" does not match on a line starting with "#". (vim-patch:7.4.1305)]],
function()
-- do_setup(true)
source([[
1 s/\%#=0\%1l^\t...//g
2 s/\%#=1\%2l^\t...//g
@ -296,8 +303,7 @@ describe('character classes in regexp', function()
4 s/\%#=0\%4l^\t...//g
5 s/\%#=1\%5l^\t...//g
6 s/\%#=2\%6l^\t...//g]])
diff(
sixlines(
local text = sixlines(
string.sub(punct1, 1)
.. digits
.. punct2
@ -308,8 +314,9 @@ describe('character classes in regexp', function()
.. ctrl2
.. iso_text
)
diff(text)
end
)
end)
it('does not convert character class ranges to an incorrect class', function()
source([[
1 s/\%#=0[0-z]//g
@ -319,9 +326,9 @@ describe('character classes in regexp', function()
5 s/\%#=1[^0-z]//g
6 s/\%#=2[^0-z]//g
]])
diff(
string.rep(ctrl1 .. punct1 .. punct4 .. ctrl2 .. iso_text .. '\n', 3)
local text = string.rep(ctrl1 .. punct1 .. punct4 .. ctrl2 .. iso_text .. '\n', 3)
.. string.rep(digits .. punct2 .. upper .. punct3 .. lower .. '\n', 3)
)
text = text:gsub('\t', ''):gsub('\n\t', '\n')
diff(text)
end)
end)

View File

@ -112,6 +112,7 @@ describe('Visual block mode', function()
line1
line2
line3
.
]])
-- Test for Visual block insert when virtualedit=all and utf-8 encoding.
@ -123,6 +124,7 @@ describe('Visual block mode', function()
x line1
x line2
x line3
.
]])
-- Test for Visual block append when virtualedit=all.
@ -132,6 +134,7 @@ describe('Visual block mode', function()
x x line1
x x line2
x x line3
.
]])
end)

View File

@ -13,8 +13,6 @@ local eval = n.eval
local eq = t.eq
local function expect_empty_buffer()
-- The space will be removed by t.dedent but is needed because dedent
-- will fail if it can not find the common indent of the given lines.
return expect('')
end
local function expect_line(line)

View File

@ -200,6 +200,7 @@ describe('eval', function()
abcFc=]])
end)
-- luacheck: ignore 611 (Line contains only whitespace)
it('appending NL with setreg()', function()
command('so test_eval_setup.vim')
@ -222,6 +223,7 @@ describe('eval', function()
command([[call SetReg('D', "\n", 'l')]])
command([[call SetReg('E', "\n")]])
command([[call SetReg('F', "\n", 'b')]])
command("$put ='.'")
expect([[
{{{2 setreg('A', ']] .. '\000' .. [[')
@ -256,7 +258,8 @@ describe('eval', function()
F: type ]] .. "\0220; value: abcF2\000 (['abcF2', '']), expr: abcF2\000" .. [[ (['abcF2', ''])
==
=abcF2=
]])
.]])
end)
it('setting and appending list with setreg()', function()

View File

@ -62,12 +62,12 @@ describe("'listchars'", function()
..bb>---<<$
...cccc><$
dd........ee<<>-$
<$
$
>-------aa>-----$
..bb>---..$
...cccc>.$
dd........ee..>-$
.$]])
$]])
end)
it('works with :list', function()

View File

@ -7,7 +7,133 @@ local eq = t.eq
describe('vim.text', function()
before_each(clear)
describe('hexencode() and hexdecode()', function()
describe('indent()', function()
it('validation', function()
t.matches('size%: expected number, got string', t.pcall_err(vim.text.indent, 'x', 'x'))
t.matches('size%: expected number, got nil', t.pcall_err(vim.text.indent, nil, 'x'))
t.matches('opts%: expected table, got string', t.pcall_err(vim.text.indent, 0, 'x', 'z'))
end)
it('basic cases', function()
-- Basic cases.
eq({ '', 0 }, { vim.text.indent(0, '') })
eq({ '', 0 }, { vim.text.indent(2, '') })
eq({ ' a', 4 }, { vim.text.indent(2, ' a') })
eq({ ' a\n b', 4 }, { vim.text.indent(2, ' a\n b') })
eq({ '\t\ta', 1 }, { vim.text.indent(2, '\ta') })
eq({ ' a\n\n', 5 }, { vim.text.indent(1, ' a\n\n') })
-- Indent 1 (tab) => 0. Starting with empty + blank lines.
eq({ '\n\naa a aa', 1 }, { vim.text.indent(0, '\n \n aa a aa') })
-- Indent 1 (tab) => 2 (tabs). Starting with empty + blank lines, 1-tab indent.
eq({ '\n\t\t\n\t\taa a aa', 1 }, { vim.text.indent(2, '\n\t\n\taa a aa') })
-- Indent 4 => 2, expandtab=false preserves tabs after the common indent.
eq(
{ ' foo\n bar\n \tbaz\n', 4 },
{ vim.text.indent(2, ' foo\n bar\n \tbaz\n') }
)
-- Indent 9 => 3, expandtab=true.
eq(
{ ' foo\n\n bar \t baz\n', 9 },
{ vim.text.indent(3, '\t foo\n\n bar \t baz\n', { expandtab = 8 }) }
)
-- Indent 9 => 8, expandtab=true.
eq(
{ ' foo\n\n bar\n', 9 },
{ vim.text.indent(8, '\t foo\n\n bar\n', { expandtab = 8 }) }
)
-- Dedent: 5 => 0.
eq({ ' foo\n\nbar\n', 5 }, { vim.text.indent(0, ' foo\n\n bar\n') })
-- Dedent: 1 => 0. Empty lines are ignored when deciding "common indent".
eq(
{ ' \n \nfoo\n\nbar\nbaz\n \n', 1 },
{ vim.text.indent(0, ' \n \n foo\n\n bar\n baz\n \n') }
)
end)
it('real-world cases', function()
-- Dedent.
eq({
[[
bufs:
nvim args: 3
lua args: {
[0] = "foo.lua"
}
]],
10,
}, {
vim.text.indent(
0,
[[
bufs:
nvim args: 3
lua args: {
[0] = "foo.lua"
}
]]
),
})
-- Indent 0 => 2.
eq({
[[
# yay
local function foo()
if true then
# yay
end
end
return
]],
0,
}, {
vim.text.indent(
2,
[[
# yay
local function foo()
if true then
# yay
end
end
return
]]
),
})
-- 1-tab indent, last line spaces < tabsize.
-- Preserves tab char immediately following the indent.
eq({ 'text\n\tmatch\nmatch\ntext\n', 1 }, {
vim.text.indent(0, (([[
text
match
match
text
]]):gsub('\n%s-([\n]?)$', '\n%1'))),
})
-- 1-tab indent, last line spaces=tabsize.
eq({ 'text\n match\nmatch\ntext\n', 6 }, {
vim.text.indent(
0,
[[
text
match
match
text
]],
{ expandtab = 6 }
),
})
end)
end)
describe('hexencode(), hexdecode()', function()
it('works', function()
local cases = {
{ 'Hello world!', '48656C6C6F20776F726C6421' },
@ -21,13 +147,13 @@ describe('vim.text', function()
end
end)
it('works with very large strings', function()
it('with very large strings', function()
local input, output = string.rep('😂', 2 ^ 16), string.rep('F09F9882', 2 ^ 16)
eq(output, vim.text.hexencode(input))
eq(input, vim.text.hexdecode(output))
end)
it('errors on invalid input', function()
it('invalid input', function()
-- Odd number of hex characters
do
local res, err = vim.text.hexdecode('ABC')

View File

@ -609,12 +609,13 @@ function M._new_argv(...)
return args, env, io_extra
end
--- Dedents string arguments and inserts the resulting text into the current buffer.
--- @param ... string
function M.insert(...)
nvim_feed('i')
for _, v in ipairs({ ... }) do
local escaped = v:gsub('<', '<lt>')
M.feed(escaped)
M.feed(escaped) -- This also dedents :P
end
nvim_feed('<ESC>')
end
@ -812,6 +813,7 @@ function M.rmdir(path)
end
end
--- @deprecated Use `t.pcall_err()` to check failure, or `n.command()` to check success.
function M.exc_exec(cmd)
M.command(([[
try

View File

@ -82,7 +82,7 @@ describe('treesitter node API', function()
]])
exec_lua(function()
local parser = vim.treesitter.get_parser(0, 'c')
local parser = assert(vim.treesitter.get_parser(0, 'c'))
local tree = parser:parse()[1]
_G.root = tree:root()
vim.treesitter.language.inspect('c')
@ -92,7 +92,7 @@ describe('treesitter node API', function()
end
end)
exec_lua 'node = root:descendant_for_range(0, 11, 0, 16)'
exec_lua 'node = root:descendant_for_range(0, 9, 0, 14)'
eq('int x', lua_eval('node_text(node)'))
exec_lua 'node = node:next_sibling()'

View File

@ -386,8 +386,8 @@ void ui_refresh(void)
[[((primitive_type) @c-keyword (#any-of? @c-keyword "int" "float"))]]
)
eq({
{ 'c-keyword', 'primitive_type', { 2, 2, 2, 5 }, 'int' },
{ 'c-keyword', 'primitive_type', { 3, 4, 3, 7 }, 'int' },
{ 'c-keyword', 'primitive_type', { 2, 0, 2, 3 }, 'int' },
{ 'c-keyword', 'primitive_type', { 3, 2, 3, 5 }, 'int' },
}, res0)
local res1 = exec_lua(
@ -401,9 +401,9 @@ void ui_refresh(void)
]]
)
eq({
{ 'fizzbuzz-strings', 'string_literal', { 6, 15, 6, 38 }, '"number= %d FizzBuzz\\n"' },
{ 'fizzbuzz-strings', 'string_literal', { 8, 15, 8, 34 }, '"number= %d Fizz\\n"' },
{ 'fizzbuzz-strings', 'string_literal', { 10, 15, 10, 34 }, '"number= %d Buzz\\n"' },
{ 'fizzbuzz-strings', 'string_literal', { 6, 13, 6, 36 }, '"number= %d FizzBuzz\\n"' },
{ 'fizzbuzz-strings', 'string_literal', { 8, 13, 8, 32 }, '"number= %d Fizz\\n"' },
{ 'fizzbuzz-strings', 'string_literal', { 10, 13, 10, 32 }, '"number= %d Buzz\\n"' },
}, res1)
end)
@ -608,9 +608,9 @@ void ui_refresh(void)
eq(
{
{ 0, 2, 0, 8 },
{ 1, 2, 1, 8 },
{ 2, 2, 2, 8 },
{ 0, 0, 0, 6 },
{ 1, 0, 1, 6 },
{ 2, 0, 2, 6 },
},
test(
[[
@ -636,9 +636,9 @@ void ui_refresh(void)
eq(
{
{ 0, 2, 0, 7 },
{ 1, 2, 1, 8 },
{ 2, 2, 2, 7 },
{ 0, 0, 0, 5 },
{ 1, 0, 1, 6 },
{ 2, 0, 2, 5 },
},
test(
[[
@ -675,9 +675,9 @@ void ui_refresh(void)
end)
eq({
{ 0, 2, 0, 12 },
{ 1, 2, 1, 12 },
{ 2, 2, 2, 12 },
{ 0, 0, 0, 10 },
{ 1, 0, 1, 10 },
{ 2, 0, 2, 10 },
}, result)
end)

View File

@ -2067,9 +2067,9 @@ describe('float window', function()
screen:expect{grid=[[
neeed some dummy |
background text |
to {1: halloj! }{23:e}ffect |
of {1: BORDAA }{24:n}ding |
of {23:b}{24:order sha}dow |
to sh{1: halloj! }{23:f}ect |
of co{1: BORDAA }{24:i}ng |
of bo{23:r}{24:der shado}w |
^ |
|
]]}

View File

@ -502,9 +502,7 @@ describe('ext_hlstate detailed highlights', function()
local num_lines = 500
insert('first line\n')
for _ = 1, num_lines do
insert([[
line
]])
api.nvim_paste(' line\n', false, -1)
end
insert('last line')

View File

@ -95,8 +95,7 @@ describe('Diff mode screen with 3 diffs open', function()
{7: }{8: 9 }{4: BBB }│{7: }{8: 9 }{4: BBB }│{7: }{8: }{23:---------------------------}|
{7: }{8: 10 }{4: BBB }│{7: }{8: 10 }{4: BBB }│{7: }{8: }{23:---------------------------}|
{7: }{8: 11 }{4:>>>>>>> branch1 }│{7: }{8: 11 }{4:>>>>>>> branch1 }│{7: }{8: }{23:---------------------------}|
{7: }{8: 12 } │{7: }{8: 12 } │{7: }{8: 6 } |
{1:~ }│{1:~ }│{1:~ }|*2
{1:~ }│{1:~ }│{1:~ }|*3
{3:<-functional-diff-screen-1.3 [+] }{2:<est-functional-diff-screen-1.2 Xtest-functional-diff-screen-1 }|
:2,6diffget screen-1.2 |
]])
@ -114,8 +113,7 @@ describe('Diff mode screen with 3 diffs open', function()
{7: }{8: 4 }{4: }{27:BBB}{4: }│{7: }{8: 6 }{4: }{27:BBB}{4: }│{7: }{8: 4 }{4: }{27:AAA}{4: }|
{7: }{8: 5 }{4: }{27:BBB}{4: }│{7: }{8: 7 }{4: }{27:BBB}{4: }│{7: }{8: 5 }{4: }{27:AAA}{4: }|
{7: }{8: }{23:---------------------------}│{7: }{8: 8 }{22:>>>>>>> branch1 }│{7: }{8: }{23:---------------------------}|
{7: }{8: 6 } │{7: }{8: 9 } │{7: }{8: 6 } |
{1:~ }│{1:~ }│{1:~ }|*5
{1:~ }│{1:~ }│{1:~ }|*6
{2:<test-functional-diff-screen-1.3 }{3:<functional-diff-screen-1.2 [+] }{2:Xtest-functional-diff-screen-1 }|
:5,7diffget screen-1.3 |
]])
@ -136,8 +134,7 @@ describe('Diff mode screen with 3 diffs open', function()
{7: }{8: 4 } BBB │{7: }{8: 9 } BBB │{7: }{8: 8 } BBB |
{7: }{8: 5 } BBB │{7: }{8: 10 } BBB │{7: }{8: 9 } BBB |
{7: }{8: }{23:---------------------------}│{7: }{8: 11 }{4:>>>>>>> branch1 }│{7: }{8: 10 }{4:>>>>>>> branch1 }|
{7: }{8: 6 } │{7: }{8: 12 } │{7: }{8: 11 } |
{1:~ }│{1:~ }│{1:~ }|*2
{1:~ }│{1:~ }│{1:~ }|*3
{2:<test-functional-diff-screen-1.3 <est-functional-diff-screen-1.2 }{3:<st-functional-diff-screen-1 [+] }|
:5,6diffget screen-1.2 |
]])
@ -158,8 +155,7 @@ describe('Diff mode screen with 3 diffs open', function()
{7: }{8: 4 }{4: BBB }│{7: }{8: 9 }{4: BBB }│{7: }{8: }{23:---------------------------}|
{7: }{8: 5 } BBB │{7: }{8: 10 } BBB │{7: }{8: 7 } BBB |
{7: }{8: }{23:---------------------------}│{7: }{8: 11 }{22:>>>>>>> branch1 }│{7: }{8: }{23:---------------------------}|
{7: }{8: 6 } │{7: }{8: 12 } │{7: }{8: 8 } |
{1:~ }│{1:~ }│{1:~ }|*2
{1:~ }│{1:~ }│{1:~ }|*3
{2:<test-functional-diff-screen-1.3 }{3:<est-functional-diff-screen-1.2 }{2:<st-functional-diff-screen-1 [+] }|
:6,8diffput screen-1 |
]])
@ -179,8 +175,7 @@ describe('Diff mode screen with 3 diffs open', function()
{7: }{8: 4 } BBB │{7: }{8: 9 } BBB │{7: }{8: 8 } BBB |
{7: }{8: 5 } BBB │{7: }{8: 10 } BBB │{7: }{8: 9 } BBB |
{7: }{8: }{23:---------------------------}│{7: }{8: 11 }{4:>>>>>>> branch1 }│{7: }{8: 10 }{4:>>>>>>> branch1 }|
{7: }{8: 6 } │{7: }{8: 12 } │{7: }{8: 11 } |
{1:~ }│{1:~ }│{1:~ }|*2
{1:~ }│{1:~ }│{1:~ }|*3
{2:<test-functional-diff-screen-1.3 }{3:<est-functional-diff-screen-1.2 }{2:<st-functional-diff-screen-1 [+] }|
:6,11diffput screen-1 |
]])
@ -276,8 +271,7 @@ something
{7: }{8: 14 }common line │{7: }{8: 15 }common line |
{7: }{8: }{23:-------------------------------------------}│{7: }{8: 16 }{22:DEF }|
{7: }{8: 15 }something │{7: }{8: 17 }something |
{7: }{8: 16 } │{7: }{8: 18 } |
{1:~ }│{1:~ }|*6
{1:~ }│{1:~ }|*7
{3:Xtest-functional-diff-screen-1.2 [+] }{2:Xtest-functional-diff-screen-1 }|
:5,9diffget |
]])
@ -300,8 +294,7 @@ something
{7: }{8: 11 }common line │{7: }{8: 12 }common line |
{7: }{8: }{23:-------------------------------------------}│{7: }{8: 13 }{22:DEF }|
{7: }{8: 12 }something │{7: }{8: 14 }something |
{7: }{8: 13 } │{7: }{8: 15 } |
{1:~ }│{1:~ }|*3
{1:~ }│{1:~ }|*4
{2:Xtest-functional-diff-screen-1.2 }{3:Xtest-functional-diff-screen-1 [+] }|
:5,10diffget |
]])
@ -322,8 +315,7 @@ something
{7: }{8: 10 }common line │{7: }{8: 10 }common line |
{7: }{8: 11 }common line │{7: }{8: 11 }common line |
{7: }{8: 12 }something │{7: }{8: 12 }something |
{7: }{8: 13 } │{7: }{8: 13 } |
{1:~ }│{1:~ }|*5
{1:~ }│{1:~ }|*6
{2:Xtest-functional-diff-screen-1.2 }{3:Xtest-functional-diff-screen-1 [+] }|
:4,17diffget |
]])
@ -349,7 +341,7 @@ something
{7: }{8: 15 }common line │{7: }{8: 15 }common line |
{7: }{8: 16 }DEF │{7: }{8: 16 }DEF |
{7: }{8: 17 }something │{7: }{8: 17 }something |
{7: }{8: 18 } │{7: }{8: 18 } |
{1:~ }│{1:~ }|
{3:Xtest-functional-diff-screen-1.2 [+] }{2:Xtest-functional-diff-screen-1 }|
:4,12diffget |
]])
@ -376,7 +368,7 @@ something
{7: }{8: 11 }common line │{7: }{8: 15 }common line |
{7: }{8: }{23:-------------------------------------------}│{7: }{8: 16 }{22:DEF }|
{7: }{8: 12 }something │{7: }{8: 17 }something |
{7: }{8: 13 } │{7: }{8: 18 } |
{1:~ }│{1:~ }|
{3:Xtest-functional-diff-screen-1.2 [+] }{2:Xtest-functional-diff-screen-1 }|
:e |
]])
@ -403,7 +395,7 @@ something
{7: }{8: 11 }common line │{7: }{8: 15 }common line |
{7: }{8: }{23:-------------------------------------------}│{7: }{8: 16 }{22:DEF }|
{7: }{8: 12 }something │{7: }{8: 17 }something |
{7: }{8: 13 } │{7: }{8: 18 } |
{1:~ }│{1:~ }|
{3:Xtest-functional-diff-screen-1.2 [+] }{2:Xtest-functional-diff-screen-1 }|
:e |
]])
@ -430,7 +422,7 @@ something
{7: }{8: 11 }common line │{7: }{8: 15 }common line |
{7: }{8: }{23:-------------------------------------------}│{7: }{8: 16 }{22:DEF }|
{7: }{8: 12 }something │{7: }{8: 17 }something |
{7: }{8: 13 } │{7: }{8: 18 } |
{1:~ }│{1:~ }|
{3:Xtest-functional-diff-screen-1.2 [+] }{2:Xtest-functional-diff-screen-1 }|
:e |
]])
@ -457,7 +449,7 @@ something
{7: }{8: 12 }^common line │{7: }{8: 15 }common line |
{7: }{8: }{23:-------------------------------------------}│{7: }{8: 16 }{22:DEF }|
{7: }{8: 13 }something │{7: }{8: 17 }something |
{7: }{8: 14 } │{7: }{8: 18 } |
{1:~ }│{1:~ }|
{3:Xtest-functional-diff-screen-1.2 [+] }{2:Xtest-functional-diff-screen-1 }|
:e |
]])
@ -484,7 +476,7 @@ something
{7: }{8: 11 }common line │{7: }{8: 15 }common line |
{7: }{8: 12 }DEF │{7: }{8: 16 }DEF |
{7: }{8: 13 }^something │{7: }{8: 17 }something |
{7: }{8: 14 } │{7: }{8: 18 } |
{1:~ }│{1:~ }|
{3:Xtest-functional-diff-screen-1.2 [+] }{2:Xtest-functional-diff-screen-1 }|
:e |
]])
@ -511,7 +503,7 @@ something
{7: }{8: 11 }common line │{7: }{8: 15 }common line |
{7: }{8: }{23:-------------------------------------------}│{7: }{8: 16 }{22:DEF }|
{7: }{8: 12 }something │{7: }{8: 17 }something |
{7: }{8: 13 } │{7: }{8: 18 } |
{1:~ }│{1:~ }|
{3:Xtest-functional-diff-screen-1.2 }{2:Xtest-functional-diff-screen-1 [+] }|
:e |
]])
@ -538,7 +530,7 @@ something
{7: }{8: 11 }common line │{7: }{8: 15 }common line |
{7: }{8: }{23:-------------------------------------------}│{7: }{8: 16 }{22:DEF }|
{7: }{8: 12 }something │{7: }{8: 17 }something |
{7: }{8: 13 } │{7: }{8: 18 } |
{1:~ }│{1:~ }|
{3:Xtest-functional-diff-screen-1.2 }{2:Xtest-functional-diff-screen-1 [+] }|
:e |
]])
@ -565,7 +557,7 @@ something
{7: }{8: 11 }common line │{7: }{8: 15 }common line |
{7: }{8: }{23:-------------------------------------------}│{7: }{8: 16 }{22:DEF }|
{7: }{8: 12 }something │{7: }{8: 17 }something |
{7: }{8: 13 } │{7: }{8: 18 } |
{1:~ }│{1:~ }|
{3:Xtest-functional-diff-screen-1.2 }{2:Xtest-functional-diff-screen-1 [+] }|
:e |
]])
@ -591,7 +583,7 @@ something
{7: }{8: 11 }^common line │{7: }{8: 14 }common line |
{7: }{8: }{23:-------------------------------------------}│{7: }{8: 15 }{22:DEF }|
{7: }{8: 12 }something │{7: }{8: 16 }something |
{7: }{8: 13 } │{7: }{8: 17 } |
{1:~ }│{1:~ }|
{1:~ }│{1:~ }|
{3:Xtest-functional-diff-screen-1.2 }{2:Xtest-functional-diff-screen-1 [+] }|
:e |
@ -618,7 +610,7 @@ something
{7: }{8: }{23:-------------------------------------------}│{7: }{8: 14 }{22:DEF }|
{7: }{8: 11 }common line │{7: }{8: 15 }common line |
{7: }{8: 12 }^something │{7: }{8: 16 }something |
{7: }{8: 13 } │{7: }{8: 17 } |
{1:~ }│{1:~ }|
{1:~ }│{1:~ }|
{3:Xtest-functional-diff-screen-1.2 }{2:Xtest-functional-diff-screen-1 [+] }|
:e |
@ -646,7 +638,7 @@ something
{7: }{8: 14 }common line │{7: }{8: 15 }common line |
{7: }{8: }{23:-------------------------------------------}│{7: }{8: 16 }{22:DEF }|
{7: }{8: 15 }something │{7: }{8: 17 }something |
{7: }{8: 16 } │{7: }{8: 18 } |
{1:~ }│{1:~ }|
{2:Xtest-functional-diff-screen-1.2 [+] }{3:Xtest-functional-diff-screen-1 }|
:e |
]])
@ -673,7 +665,7 @@ something
{7: }{8: 14 }common line │{7: }{8: 15 }common line |
{7: }{8: }{23:-------------------------------------------}│{7: }{8: 16 }{22:DEF }|
{7: }{8: 15 }something │{7: }{8: 17 }something |
{7: }{8: 16 } │{7: }{8: 18 } |
{1:~ }│{1:~ }|
{2:Xtest-functional-diff-screen-1.2 [+] }{3:Xtest-functional-diff-screen-1 }|
:e |
]])
@ -700,7 +692,7 @@ something
{7: }{8: 11 }common line │{7: }{8: 15 }common line |
{7: }{8: }{23:-------------------------------------------}│{7: }{8: 16 }{22:DEF }|
{7: }{8: 12 }something │{7: }{8: 17 }something |
{7: }{8: 13 } │{7: }{8: 18 } |
{1:~ }│{1:~ }|
{2:Xtest-functional-diff-screen-1.2 [+] }{3:Xtest-functional-diff-screen-1 }|
:e |
]])
@ -727,7 +719,7 @@ something
{7: }{8: 11 }common line │{7: }{8: 15 }common line |
{7: }{8: 12 }DEF │{7: }{8: 16 }DEF |
{7: }{8: 13 }something │{7: }{8: 17 }^something |
{7: }{8: 14 } │{7: }{8: 18 } |
{1:~ }│{1:~ }|
{2:Xtest-functional-diff-screen-1.2 [+] }{3:Xtest-functional-diff-screen-1 }|
:e |
]])
@ -757,8 +749,7 @@ d
{7: }{8: 2 }{4:abc d }│{7: }{8: 1 }{27:// }{4:abc d }|
{7: }{8: 3 }{4:d }│{7: }{8: 2 }{27:// }{4:d }|
{7: }{8: }{23:-------------------------------------------}│{7: }{8: 3 }{22:// d }|
{7: }{8: 4 } │{7: }{8: 4 } |
{1:~ }│{1:~ }|*13
{1:~ }│{1:~ }|*14
{3:Xtest-functional-diff-screen-1.2 }{2:Xtest-functional-diff-screen-1 }|
:e |
]])
@ -794,8 +785,7 @@ void testFunction () {
{7: }{8: 3 }{4: }{27:// }{4:} }│{7: }{8: 4 }{4: } }|
{7: }{8: }{23:-------------------------------------------}│{7: }{8: 5 }{22: } }|
{7: }{8: 4 }} │{7: }{8: 6 }} |
{7: }{8: 5 } │{7: }{8: 7 } |
{1:~ }│{1:~ }|*11
{1:~ }│{1:~ }|*12
{3:Xtest-functional-diff-screen-1.2 }{2:Xtest-functional-diff-screen-1 }|
:e |
]])
@ -834,8 +824,7 @@ void testFunction () {
{7: }{8: 6 }{22:?B }│{7: }{8: }{23:--------------------------------------------}|
{7: }{8: 7 }{22:?B }│{7: }{8: }{23:--------------------------------------------}|
{7: }{8: 8 }{22:?C }│{7: }{8: }{23:--------------------------------------------}|
{7: }{8: 9 } │{7: }{8: 4 } |
{1:~ }│{1:~ }|*9
{1:~ }│{1:~ }|*10
{3:Xtest-functional-diff-screen-1.2 }{2:Xtest-functional-diff-screen-1 }|
:e |
]])
@ -874,8 +863,7 @@ void testFunction () {
{7: }{8: 6 }{27:?}{4:B }│{7: }{8: 2 }{27:!}{4:B }|
{7: }{8: 7 }{27:?}{4:C }│{7: }{8: 3 }{27:!}{4:C }|
{7: }{8: 8 }{22:?C }│{7: }{8: }{23:--------------------------------------------}|
{7: }{8: 9 } │{7: }{8: 4 } |
{1:~ }│{1:~ }|*9
{1:~ }│{1:~ }|*10
{3:Xtest-functional-diff-screen-1.2 }{2:Xtest-functional-diff-screen-1 }|
:e |
]])
@ -1017,8 +1005,7 @@ something
{7: }{8: 9 }HIL │{7: }{8: 9 }HIL |
{7: }{8: 10 }common line │{7: }{8: 10 }common line |
{7: }{8: 11 }something │{7: }{8: 11 }something |
{7: }{8: 12 } │{7: }{8: 12 } |
{1:~ }│{1:~ }|*6
{1:~ }│{1:~ }|*7
{3:Xtest-functional-diff-screen-1.2 [+] }{2:Xtest-functional-diff-screen-1 }|
:1,19diffget |
]])

View File

@ -2329,14 +2329,15 @@ describe('builtin popupmenu', function()
occaecat cupidatat non proident, sunt in culpa
qui officia deserunt mollit anim id est
laborum.
.
]])
screen:expect([[
reprehenderit in voluptate velit esse cillum |
dolore eu fugiat nulla pariatur. Excepteur sint |
occaecat cupidatat non proident, sunt in culpa |
qui officia deserunt mollit anim id est |
laborum. |
. |
^ |
{4:[No Name] [+] }|
Lorem ipsum dolor sit amet, consectetur |

View File

@ -494,9 +494,8 @@ function Screen:expect(expected, attr_ids, ...)
local expected_rows = {} --- @type string[]
if grid then
-- Remove the last line and dedent. Note that gsub returns more then one
-- value.
grid = dedent(grid:gsub('\n[ ]+$', ''), 0)
-- Dedent (ignores last line if it is blank).
grid = dedent(grid, 0)
for row in grid:gmatch('[^\n]+') do
table.insert(expected_rows, row)
end

View File

@ -652,7 +652,7 @@ describe('search highlighting', function()
topline = 0,
botline = 3,
curline = 0,
curcol = 11,
curcol = 9,
linecount = 2,
sum_scroll_delta = 0,
},

View File

@ -148,6 +148,7 @@ end
--- @param actual string
--- @return boolean
function M.matches(pat, actual)
assert(pat and pat ~= '', 'pat must be a non-empty string')
if nil ~= string.match(actual, pat) then
return true
end
@ -641,28 +642,9 @@ end
--- @param leave_indent? integer
--- @return string
function M.dedent(str, leave_indent)
-- find minimum common indent across lines
local indent --- @type string?
for line in str:gmatch('[^\n]+') do
local line_indent = line:match('^%s+') or ''
if indent == nil or #line_indent < #indent then
indent = line_indent
end
end
if not indent or #indent == 0 then
-- no minimum common indent
return str
end
local left_indent = (' '):rep(leave_indent or 0)
-- create a pattern for the indent
indent = indent:gsub('%s', '[ \t]')
-- strip it from the first line
str = str:gsub('^' .. indent, left_indent)
-- strip it from the remaining lines
str = str:gsub('[\n]' .. indent, '\n' .. left_indent)
return str
-- Last blank line often has non-matching indent, so remove it.
str = str:gsub('\n[ ]+$', '\n')
return (vim.text.indent(leave_indent or 0, str))
end
function M.intchar2lua(ch)