neovim

Neovim text editor
git clone https://git.dasho.dev/neovim.git
Log | Files | Refs | README

testutil.lua (24551B)


      1 local luaassert = require('luassert')
      2 local busted = require('busted')
      3 local uv = vim.uv
      4 local Paths = require('test.cmakeconfig.paths')
      5 
      6 luaassert:set_parameter('TableFormatLevel', 100)
      7 
      8 --- Functions executing in the context of the test runner (not the current nvim test session).
      9 --- @class test.testutil
     10 local M = {
     11  paths = Paths,
     12 }
     13 
     14 --- @param path string
     15 --- @return boolean
     16 function M.isdir(path)
     17  if not path then
     18    return false
     19  end
     20  local stat = uv.fs_stat(path)
     21  if not stat then
     22    return false
     23  end
     24  return stat.type == 'directory'
     25 end
     26 
     27 --- (Only on Windows) Replaces yucky "\\" slashes with delicious "/" slashes in a string, or all
     28 --- string values in a table (recursively).
     29 ---
     30 --- @generic T: string|table
     31 --- @param obj T
     32 --- @return T|nil
     33 function M.fix_slashes(obj)
     34  if not M.is_os('win') then
     35    return obj
     36  end
     37  if type(obj) == 'string' then
     38    local ret = string.gsub(obj, '\\', '/')
     39    return ret
     40  elseif type(obj) == 'table' then
     41    --- @cast obj table<any,any>
     42    local ret = {} --- @type table<any,any>
     43    for k, v in pairs(obj) do
     44      ret[k] = M.fix_slashes(v)
     45    end
     46    return ret
     47  end
     48  assert(false, 'expected string or table of strings, got ' .. type(obj))
     49 end
     50 
     51 --- @param ... string|string[]
     52 --- @return string[]
     53 function M.argss_to_cmd(...)
     54  local cmd = {} --- @type string[]
     55  for i = 1, select('#', ...) do
     56    local arg = select(i, ...)
     57    if type(arg) == 'string' then
     58      cmd[#cmd + 1] = arg
     59    else
     60      --- @cast arg string[]
     61      for _, subarg in ipairs(arg) do
     62        cmd[#cmd + 1] = subarg
     63      end
     64    end
     65  end
     66  return cmd
     67 end
     68 
     69 --- Calls fn() until it succeeds, up to `max` times or until `max_ms`
     70 --- milliseconds have passed.
     71 --- @param max integer?
     72 --- @param max_ms integer?
     73 --- @param fn function
     74 --- @return any
     75 function M.retry(max, max_ms, fn)
     76  assert(max == nil or max > 0)
     77  assert(max_ms == nil or max_ms > 0)
     78  local tries = 1
     79  local timeout = (max_ms and max_ms or 10000)
     80  local start_time = uv.now()
     81  while true do
     82    --- @type boolean, any
     83    local status, result = pcall(fn)
     84    if status then
     85      return result
     86    end
     87    uv.update_time() -- Update cached value of luv.now() (libuv: uv_now()).
     88    if (max and tries >= max) or (uv.now() - start_time > timeout) then
     89      busted.fail(string.format('retry() attempts: %d\n%s', tries, tostring(result)), 2)
     90    end
     91    tries = tries + 1
     92    uv.sleep(20) -- Avoid hot loop...
     93  end
     94 end
     95 
     96 local check_logs_useless_lines = {
     97  ['Warning: noted but unhandled ioctl'] = 1,
     98  ['could cause spurious value errors to appear'] = 2,
     99  ['See README_MISSING_SYSCALL_OR_IOCTL for guidance'] = 3,
    100 }
    101 
    102 function M.eq(expected, actual, context)
    103  return luaassert.are.same(expected, actual, context)
    104 end
    105 function M.neq(expected, actual, context)
    106  return luaassert.are_not.same(expected, actual, context)
    107 end
    108 
    109 --- Asserts that `cond` is true, or prints a message.
    110 ---
    111 --- @param cond (boolean) expression to assert
    112 --- @param expected (any) description of expected result
    113 --- @param actual (any) description of actual result
    114 function M.ok(cond, expected, actual)
    115  assert(
    116    (not expected and not actual) or (expected and actual),
    117    'if "expected" is given, "actual" is also required'
    118  )
    119  local msg = expected and ('expected %s, got: %s'):format(expected, tostring(actual)) or nil
    120  return assert(cond, msg)
    121 end
    122 
    123 local function epicfail(state, arguments, _)
    124  state.failure_message = arguments[1]
    125  return false
    126 end
    127 luaassert:register('assertion', 'epicfail', epicfail)
    128 function M.fail(msg)
    129  return luaassert.epicfail(msg)
    130 end
    131 
    132 --- @param pat string
    133 --- @param actual string
    134 --- @return boolean
    135 function M.matches(pat, actual)
    136  assert(pat and pat ~= '', 'pat must be a non-empty string')
    137  if nil ~= string.match(actual, pat) then
    138    return true
    139  end
    140  error(string.format('Pattern does not match.\nPattern:\n%s\nActual:\n%s', pat, actual))
    141 end
    142 
    143 --- Asserts that `pat` matches (or *not* if inverse=true) any text in the tail of `logfile`.
    144 ---
    145 --- Matches are not restricted to a single line.
    146 ---
    147 --- Retries for 1 second in case of filesystem delay.
    148 ---
    149 ---@param pat (string) Lua pattern to match text in the log file
    150 ---@param logfile? (string) Full path to log file (default=$NVIM_LOG_FILE)
    151 ---@param nrlines? (number) Search up to this many log lines (default 10)
    152 ---@param inverse? (boolean) Assert that the pattern does NOT match.
    153 function M.assert_log(pat, logfile, nrlines, inverse)
    154  logfile = logfile or os.getenv('NVIM_LOG_FILE') or 'nvim.log'
    155  assert(logfile ~= nil, 'no logfile')
    156  nrlines = nrlines or 10
    157 
    158  M.retry(nil, 1000, function()
    159    local lines = M.read_file_list(logfile, -nrlines) or {}
    160    local text = table.concat(lines, '\n')
    161    local ismatch = not not text:match(pat)
    162    if (ismatch and inverse) or not (ismatch or inverse) then
    163      local msg = string.format(
    164        'Pattern %s %sfound in log (last %d lines): %q:\n%s',
    165        vim.inspect(pat),
    166        (inverse and '' or 'not '),
    167        nrlines,
    168        logfile,
    169        vim.text.indent(4, text)
    170      )
    171      error(msg)
    172    end
    173  end)
    174 end
    175 
    176 --- Asserts that `pat` does NOT match any line in the tail of `logfile`.
    177 ---
    178 --- @see assert_log
    179 --- @param pat (string) Lua pattern to match lines in the log file
    180 --- @param logfile? (string) Full path to log file (default=$NVIM_LOG_FILE)
    181 --- @param nrlines? (number) Search up to this many log lines
    182 function M.assert_nolog(pat, logfile, nrlines)
    183  return M.assert_log(pat, logfile, nrlines, true)
    184 end
    185 
    186 --- @param fn fun(...): any
    187 --- @param ... any
    188 --- @return boolean, any
    189 function M.pcall(fn, ...)
    190  assert(type(fn) == 'function')
    191  local status, rv = pcall(fn, ...)
    192  if status then
    193    return status, rv
    194  end
    195 
    196  -- From:
    197  --    C:/long/path/foo.lua:186: Expected string, got number
    198  -- to:
    199  --    .../foo.lua:0: Expected string, got number
    200  local errmsg = tostring(rv)
    201    :gsub('([%s<])vim[/\\]([^%s:/\\]+):%d+', '%1\xffvim\xff%2:0')
    202    :gsub('[^%s<]-[/\\]([^%s:/\\]+):%d+', '.../%1:0')
    203    :gsub('\xffvim\xff', 'vim/')
    204 
    205  -- Scrub numbers in paths/stacktraces:
    206  --    shared.lua:0: in function 'gsplit'
    207  --    shared.lua:0: in function <shared.lua:0>'
    208  errmsg = errmsg:gsub('([^%s].lua):%d+', '%1:0')
    209  --    [string "<nvim>"]:0:
    210  --    [string ":lua"]:0:
    211  --    [string ":luado"]:0:
    212  errmsg = errmsg:gsub('(%[string "[^"]+"%]):%d+', '%1:0')
    213 
    214  -- Scrub tab chars:
    215  errmsg = errmsg:gsub('\t', '    ')
    216  -- In Lua 5.1, we sometimes get a "(tail call): ?" on the last line.
    217  --    We remove this so that the tests are not lua dependent.
    218  errmsg = errmsg:gsub('%s*%(tail call%): %?', '')
    219 
    220  return status, errmsg
    221 end
    222 
    223 -- Invokes `fn` and returns the error string (with truncated paths), or raises
    224 -- an error if `fn` succeeds.
    225 --
    226 -- Replaces line/column numbers with zero:
    227 --     shared.lua:0: in function 'gsplit'
    228 --     shared.lua:0: in function <shared.lua:0>'
    229 --
    230 -- Usage:
    231 --    -- Match exact string.
    232 --    eq('e', pcall_err(function(a, b) error('e') end, 'arg1', 'arg2'))
    233 --    -- Match Lua pattern.
    234 --    matches('e[or]+$', pcall_err(function(a, b) error('some error') end, 'arg1', 'arg2'))
    235 --
    236 --- @param fn function
    237 --- @return string
    238 function M.pcall_err_withfile(fn, ...)
    239  assert(type(fn) == 'function')
    240  local status, rv = M.pcall(fn, ...)
    241  if status == true then
    242    error('expected failure, but got success')
    243  end
    244  return rv
    245 end
    246 
    247 --- @param fn function
    248 --- @param ... any
    249 --- @return string
    250 function M.pcall_err_withtrace(fn, ...)
    251  local errmsg = M.pcall_err_withfile(fn, ...)
    252 
    253  return (
    254    errmsg
    255      :gsub('^%.%.%./testnvim%.lua:0: ', '')
    256      :gsub('^Lua:- ', '')
    257      :gsub('^%[string "<nvim>"%]:0: ', '')
    258  )
    259 end
    260 
    261 --- @param fn function
    262 --- @param ... any
    263 --- @return string
    264 function M.pcall_err(fn, ...)
    265  return M.remove_trace(M.pcall_err_withtrace(fn, ...))
    266 end
    267 
    268 --- @param s string
    269 --- @return string
    270 function M.remove_trace(s)
    271  return (s:gsub('\n%s*stack traceback:.*', ''))
    272 end
    273 
    274 -- initial_path:  directory to recurse into
    275 -- re:            include pattern (string)
    276 -- exc_re:        exclude pattern(s) (string or table)
    277 function M.glob(initial_path, re, exc_re)
    278  exc_re = type(exc_re) == 'table' and exc_re or { exc_re }
    279  local paths_to_check = { initial_path } --- @type string[]
    280  local ret = {} --- @type string[]
    281  local checked_files = {} --- @type table<string,true>
    282  local function is_excluded(path)
    283    for _, pat in pairs(exc_re) do
    284      if path:match(pat) then
    285        return true
    286      end
    287    end
    288    return false
    289  end
    290 
    291  if is_excluded(initial_path) then
    292    return ret
    293  end
    294  while #paths_to_check > 0 do
    295    local cur_path = paths_to_check[#paths_to_check]
    296    paths_to_check[#paths_to_check] = nil
    297    for e in vim.fs.dir(cur_path) do
    298      local full_path = cur_path .. '/' .. e
    299      local checked_path = full_path:sub(#initial_path + 1)
    300      if (not is_excluded(checked_path)) and e:sub(1, 1) ~= '.' then
    301        local stat = uv.fs_stat(full_path)
    302        if stat then
    303          local check_key = stat.dev .. ':' .. tostring(stat.ino)
    304          if not checked_files[check_key] then
    305            checked_files[check_key] = true
    306            if stat.type == 'directory' then
    307              paths_to_check[#paths_to_check + 1] = full_path
    308            elseif not re or checked_path:match(re) then
    309              ret[#ret + 1] = full_path
    310            end
    311          end
    312        end
    313      end
    314    end
    315  end
    316  return ret
    317 end
    318 
    319 function M.check_logs()
    320  local log_dir = os.getenv('LOG_DIR')
    321  local runtime_errors = {}
    322  if log_dir and M.isdir(log_dir) then
    323    for tail in vim.fs.dir(log_dir) do
    324      if tail:sub(1, 30) == 'valgrind-' or tail:find('san%.') then
    325        local file = log_dir .. '/' .. tail
    326        local fd = assert(io.open(file))
    327        local start_msg = ('='):rep(20) .. ' File ' .. file .. ' ' .. ('='):rep(20)
    328        local lines = {} --- @type string[]
    329        local warning_line = 0
    330        for line in fd:lines() do
    331          local cur_warning_line = check_logs_useless_lines[line]
    332          if cur_warning_line == warning_line + 1 then
    333            warning_line = cur_warning_line
    334          else
    335            lines[#lines + 1] = line
    336          end
    337        end
    338        fd:close()
    339        if #lines > 0 then
    340          --- @type boolean?, file*?
    341          local status, f
    342          local out = io.stdout
    343          if os.getenv('SYMBOLIZER') then
    344            status, f = pcall(M.repeated_read_cmd, os.getenv('SYMBOLIZER'), '-l', file)
    345          end
    346          out:write(start_msg .. '\n')
    347          if status then
    348            assert(f)
    349            for line in f:lines() do
    350              out:write('= ' .. line .. '\n')
    351            end
    352            f:close()
    353          else
    354            out:write('= ' .. table.concat(lines, '\n= ') .. '\n')
    355          end
    356          out:write(select(1, start_msg:gsub('.', '=')) .. '\n')
    357          table.insert(runtime_errors, file)
    358        end
    359        os.remove(file)
    360      end
    361    end
    362  end
    363  luaassert(
    364    0 == #runtime_errors,
    365    string.format('Found runtime errors in logfile(s): %s', table.concat(runtime_errors, ', '))
    366  )
    367 end
    368 
    369 local sysname = uv.os_uname().sysname:lower()
    370 
    371 --- @param s 'win'|'mac'|'linux'|'freebsd'|'openbsd'|'bsd'
    372 --- @return boolean
    373 function M.is_os(s)
    374  if
    375    not (s == 'win' or s == 'mac' or s == 'linux' or s == 'freebsd' or s == 'openbsd' or s == 'bsd')
    376  then
    377    error('unknown platform: ' .. tostring(s))
    378  end
    379  return not not (
    380    (s == 'win' and (sysname:find('windows') or sysname:find('mingw')))
    381    or (s == 'mac' and sysname == 'darwin')
    382    or (s == 'linux' and sysname == 'linux')
    383    or (s == 'freebsd' and sysname == 'freebsd')
    384    or (s == 'openbsd' and sysname == 'openbsd')
    385    or (s == 'bsd' and sysname:find('bsd'))
    386  )
    387 end
    388 
    389 local architecture = uv.os_uname().machine
    390 
    391 --- @param s 'x86_64'|'arm64'
    392 --- @return boolean
    393 function M.is_arch(s)
    394  if not (s == 'x86_64' or s == 'arm64') then
    395    error('unknown architecture: ' .. tostring(s))
    396  end
    397  return s == architecture
    398 end
    399 
    400 function M.is_asan()
    401  return M.paths.is_asan
    402 end
    403 
    404 function M.is_zig_build()
    405  return M.paths.is_zig_build
    406 end
    407 
    408 local tmpname_id = 0
    409 local tmpdir = os.getenv('TMPDIR') or os.getenv('TEMP')
    410 local tmpdir_is_local = not not (tmpdir and tmpdir:find('Xtest'))
    411 
    412 local function get_tmpname()
    413  if tmpdir_is_local then
    414    -- Cannot control os.tmpname() dir, so hack our own tmpname() impl.
    415    tmpname_id = tmpname_id + 1
    416    -- "…/Xtest_tmpdir/T42.7"
    417    return ('%s/%s.%d'):format(tmpdir, (_G._nvim_test_id or 'nvim-test'), tmpname_id)
    418  end
    419 
    420  local fname = os.tmpname()
    421 
    422  if M.is_os('win') and fname:sub(1, 2) == '\\s' then
    423    -- In Windows tmpname() returns a filename starting with
    424    -- special sequence \s, prepend $TEMP path
    425    return tmpdir .. fname
    426  elseif M.is_os('mac') and fname:match('^/tmp') then
    427    -- In OS X /tmp links to /private/tmp
    428    return '/private' .. fname
    429  end
    430  return fname
    431 end
    432 
    433 --- Generates a unique filepath for use by tests, in a test-specific "…/Xtest_tmpdir/T42.7"
    434 --- directory (which is cleaned up by the test runner).
    435 ---
    436 --- @param create? boolean (default true) Create the file.
    437 --- @return string
    438 function M.tmpname(create)
    439  local fname = get_tmpname()
    440  os.remove(fname)
    441  if create ~= false then
    442    assert(io.open(fname, 'w')):close()
    443  end
    444  return fname
    445 end
    446 
    447 local function deps_prefix()
    448  local env = os.getenv('DEPS_PREFIX')
    449  return (env and env ~= '') and env or '.deps/usr'
    450 end
    451 
    452 local tests_skipped = 0
    453 
    454 function M.check_cores(app, force) -- luacheck: ignore
    455  -- Temporary workaround: skip core check as it interferes with CI.
    456  if true then
    457    return
    458  end
    459  app = app or 'build/bin/nvim' -- luacheck: ignore
    460  --- @type string, string?, string[]
    461  local initial_path, re, exc_re
    462  local gdb_db_cmd =
    463    'gdb -n -batch -ex "thread apply all bt full" "$_NVIM_TEST_APP" -c "$_NVIM_TEST_CORE"'
    464  local lldb_db_cmd = 'lldb -Q -o "bt all" -f "$_NVIM_TEST_APP" -c "$_NVIM_TEST_CORE"'
    465  local random_skip = false
    466  -- Workspace-local $TMPDIR, scrubbed and pattern-escaped.
    467  -- "./Xtest-tmpdir/" => "Xtest%-tmpdir"
    468  local local_tmpdir = nil
    469  if tmpdir_is_local and tmpdir then
    470    local_tmpdir =
    471      vim.pesc(vim.fs.relpath(assert(vim.uv.cwd()), tmpdir):gsub('^[ ./]+', ''):gsub('%/+$', ''))
    472  end
    473 
    474  local db_cmd --- @type string
    475  local test_glob_dir = os.getenv('NVIM_TEST_CORE_GLOB_DIRECTORY')
    476  if test_glob_dir and test_glob_dir ~= '' then
    477    initial_path = test_glob_dir
    478    re = os.getenv('NVIM_TEST_CORE_GLOB_RE')
    479    exc_re = { os.getenv('NVIM_TEST_CORE_EXC_RE'), local_tmpdir }
    480    db_cmd = os.getenv('NVIM_TEST_CORE_DB_CMD') or gdb_db_cmd
    481    random_skip = os.getenv('NVIM_TEST_CORE_RANDOM_SKIP') ~= ''
    482  elseif M.is_os('mac') then
    483    initial_path = '/cores'
    484    re = nil
    485    exc_re = { local_tmpdir }
    486    db_cmd = lldb_db_cmd
    487  else
    488    initial_path = '.'
    489    if M.is_os('freebsd') then
    490      re = '/nvim.core$'
    491    else
    492      re = '/core[^/]*$'
    493    end
    494    exc_re = { '^/%.deps$', '^/%' .. deps_prefix() .. '$', local_tmpdir, '^/%node_modules$' }
    495    db_cmd = gdb_db_cmd
    496    random_skip = true
    497  end
    498  -- Finding cores takes too much time on linux
    499  if not force and random_skip and math.random() < 0.9 then
    500    tests_skipped = tests_skipped + 1
    501    return
    502  end
    503  local cores = M.glob(initial_path, re, exc_re)
    504  local found_cores = 0
    505  local out = io.stdout
    506  for _, core in ipairs(cores) do
    507    local len = 80 - #core - #'Core file ' - 2
    508    local esigns = ('='):rep(len / 2)
    509    out:write(('\n%s Core file %s %s\n'):format(esigns, core, esigns))
    510    out:flush()
    511    os.execute(db_cmd:gsub('%$_NVIM_TEST_APP', app):gsub('%$_NVIM_TEST_CORE', core) .. ' 2>&1')
    512    out:write('\n')
    513    found_cores = found_cores + 1
    514    os.remove(core)
    515  end
    516  if found_cores ~= 0 then
    517    out:write(('\nTests covered by this check: %u\n'):format(tests_skipped + 1))
    518  end
    519  tests_skipped = 0
    520  if found_cores > 0 then
    521    error('crash detected (see above)')
    522  end
    523 end
    524 
    525 --- @return string?
    526 function M.repeated_read_cmd(...)
    527  local cmd = M.argss_to_cmd(...)
    528  local data = {}
    529  local got_code = nil
    530  local stdout = assert(vim.uv.new_pipe(false))
    531  local handle = assert(
    532    vim.uv.spawn(
    533      cmd[1],
    534      { args = vim.list_slice(cmd, 2), stdio = { nil, stdout, 2 }, hide = true },
    535      function(code, _signal)
    536        got_code = code
    537      end
    538    )
    539  )
    540  stdout:read_start(function(err, chunk)
    541    if err or chunk == nil then
    542      stdout:read_stop()
    543      stdout:close()
    544    else
    545      table.insert(data, chunk)
    546    end
    547  end)
    548 
    549  while not stdout:is_closing() or got_code == nil do
    550    vim.uv.run('once')
    551  end
    552 
    553  if got_code ~= 0 then
    554    error('command ' .. vim.inspect(cmd) .. 'unexpectedly exited with status ' .. got_code)
    555  end
    556  handle:close()
    557  return table.concat(data)
    558 end
    559 
    560 --- @generic T
    561 --- @param orig T
    562 --- @return T
    563 function M.shallowcopy(orig)
    564  if type(orig) ~= 'table' then
    565    return orig
    566  end
    567  --- @cast orig table<any,any>
    568  local copy = {} --- @type table<any,any>
    569  for orig_key, orig_value in pairs(orig) do
    570    copy[orig_key] = orig_value
    571  end
    572  return copy
    573 end
    574 
    575 --- @param d1 table<any,any>
    576 --- @param d2 table<any,any>
    577 --- @return table<any,any>
    578 function M.mergedicts_copy(d1, d2)
    579  local ret = M.shallowcopy(d1)
    580  for k, v in pairs(d2) do
    581    if d2[k] == vim.NIL then
    582      ret[k] = nil
    583    elseif type(d1[k]) == 'table' and type(v) == 'table' then
    584      ret[k] = M.mergedicts_copy(d1[k], v)
    585    else
    586      ret[k] = v
    587    end
    588  end
    589  return ret
    590 end
    591 
    592 --- dictdiff: find a diff so that mergedicts_copy(d1, diff) is equal to d2
    593 ---
    594 --- Note: does not do copies of d2 values used.
    595 --- @param d1 table<any,any>
    596 --- @param d2 table<any,any>
    597 function M.dictdiff(d1, d2)
    598  local ret = {} --- @type table<any,any>
    599  local hasdiff = false
    600  for k, v in pairs(d1) do
    601    if d2[k] == nil then
    602      hasdiff = true
    603      ret[k] = vim.NIL
    604    elseif type(v) == type(d2[k]) then
    605      if type(v) == 'table' then
    606        local subdiff = M.dictdiff(v, d2[k])
    607        if subdiff ~= nil then
    608          hasdiff = true
    609          ret[k] = subdiff
    610        end
    611      elseif v ~= d2[k] then
    612        ret[k] = d2[k]
    613        hasdiff = true
    614      end
    615    else
    616      ret[k] = d2[k]
    617      hasdiff = true
    618    end
    619  end
    620  local shallowcopy = M.shallowcopy
    621  for k, v in pairs(d2) do
    622    if d1[k] == nil then
    623      ret[k] = shallowcopy(v)
    624      hasdiff = true
    625    end
    626  end
    627  if hasdiff then
    628    return ret
    629  else
    630    return nil
    631  end
    632 end
    633 
    634 -- Concat list-like tables.
    635 function M.concat_tables(...)
    636  local ret = {} --- @type table<any,any>
    637  for i = 1, select('#', ...) do
    638    --- @type table<any,any>
    639    local tbl = select(i, ...)
    640    if tbl then
    641      for _, v in ipairs(tbl) do
    642        ret[#ret + 1] = v
    643      end
    644    end
    645  end
    646  return ret
    647 end
    648 
    649 --- Get all permutations of an array.
    650 ---
    651 --- @param arr any[]
    652 --- @return any[][]
    653 function M.permutations(arr)
    654  local res = {} --- @type any[][]
    655  --- @param a any[]
    656  --- @param n integer
    657  local function gen(a, n)
    658    if n == 0 then
    659      res[#res + 1] = M.shallowcopy(a)
    660      return
    661    end
    662    for i = 1, n do
    663      a[n], a[i] = a[i], a[n]
    664      gen(a, n - 1)
    665      a[n], a[i] = a[i], a[n]
    666    end
    667  end
    668  gen(M.shallowcopy(arr), #arr)
    669  return res
    670 end
    671 
    672 --- @param str string
    673 --- @param leave_indent? integer
    674 --- @return string
    675 function M.dedent(str, leave_indent)
    676  -- Last blank line often has non-matching indent, so remove it.
    677  str = str:gsub('\n[ ]+$', '\n')
    678  return (vim.text.indent(leave_indent or 0, str))
    679 end
    680 
    681 function M.intchar2lua(ch)
    682  ch = tonumber(ch)
    683  return (20 <= ch and ch < 127) and ('%c'):format(ch) or ch
    684 end
    685 
    686 --- @param str string
    687 --- @return string
    688 function M.hexdump(str)
    689  local len = string.len(str)
    690  local dump = ''
    691  local hex = ''
    692  local asc = ''
    693 
    694  for i = 1, len do
    695    if 1 == i % 8 then
    696      dump = dump .. hex .. asc .. '\n'
    697      hex = string.format('%04x: ', i - 1)
    698      asc = ''
    699    end
    700 
    701    local ord = string.byte(str, i)
    702    hex = hex .. string.format('%02x ', ord)
    703    if ord >= 32 and ord <= 126 then
    704      asc = asc .. string.char(ord)
    705    else
    706      asc = asc .. '.'
    707    end
    708  end
    709 
    710  return dump .. hex .. string.rep('   ', 8 - len % 8) .. asc
    711 end
    712 
    713 --- Reads text lines from `filename` into a table.
    714 --- @param filename string path to file
    715 --- @param start? integer start line (1-indexed), negative means "lines before end" (tail)
    716 --- @return string[]?
    717 function M.read_file_list(filename, start)
    718  local lnum = (start ~= nil and type(start) == 'number') and start or 1
    719  local tail = (lnum < 0)
    720  local maxlines = tail and math.abs(lnum) or nil
    721  local file = io.open(filename, 'r')
    722  if not file then
    723    return nil
    724  end
    725 
    726  -- There is no need to read more than the last 2MB of the log file, so seek
    727  -- to that.
    728  local file_size = file:seek('end')
    729  local offset = file_size - 2000000
    730  if offset < 0 then
    731    offset = 0
    732  end
    733  file:seek('set', offset)
    734 
    735  local lines = {}
    736  local i = 1
    737  local line = file:read('*l')
    738  while line ~= nil do
    739    if i >= start then
    740      table.insert(lines, line)
    741      if #lines > maxlines then
    742        table.remove(lines, 1)
    743      end
    744    end
    745    i = i + 1
    746    line = file:read('*l')
    747  end
    748  file:close()
    749  return lines
    750 end
    751 
    752 --- Reads the entire contents of `filename` into a string.
    753 --- @param filename string
    754 --- @return string?
    755 function M.read_file(filename)
    756  local file = io.open(filename, 'r')
    757  if not file then
    758    return nil
    759  end
    760  local ret = file:read('*a')
    761  file:close()
    762  return ret
    763 end
    764 
    765 -- Dedent the given text and write it to the file name.
    766 function M.write_file(name, text, no_dedent, append)
    767  local file = assert(io.open(name, (append and 'a' or 'w')))
    768  if type(text) == 'table' then
    769    -- Byte blob
    770    --- @type string[]
    771    local bytes = text
    772    text = ''
    773    for _, char in ipairs(bytes) do
    774      text = ('%s%c'):format(text, char)
    775    end
    776  elseif not no_dedent then
    777    text = M.dedent(text)
    778  end
    779  file:write(text)
    780  file:flush()
    781  file:close()
    782 end
    783 
    784 --- @param name? 'cirrus'|'github'
    785 --- @return boolean
    786 function M.is_ci(name)
    787  local any = (name == nil)
    788  assert(any or name == 'github' or name == 'cirrus')
    789  local gh = ((any or name == 'github') and nil ~= os.getenv('GITHUB_ACTIONS'))
    790  local cirrus = ((any or name == 'cirrus') and nil ~= os.getenv('CIRRUS_CI'))
    791  return gh or cirrus
    792 end
    793 
    794 -- Gets the (tail) contents of `logfile`.
    795 -- Also moves the file to "${NVIM_LOG_FILE}.displayed" on CI environments.
    796 function M.read_nvim_log(logfile, ci_rename)
    797  logfile = logfile or os.getenv('NVIM_LOG_FILE') or 'nvim.log'
    798  assert(uv.fs_stat(logfile), ('logfile not found: %q'):format(logfile))
    799  local is_ci = M.is_ci()
    800  local keep = is_ci and 100 or 10
    801  local lines = M.read_file_list(logfile, -keep) or {}
    802  local log = (
    803    ('-'):rep(78)
    804    .. '\n'
    805    .. string.format('$NVIM_LOG_FILE: %s\n', logfile)
    806    .. (#lines > 0 and '(last ' .. tostring(keep) .. ' lines)\n' or '(empty)\n')
    807  )
    808  for _, line in ipairs(lines) do
    809    log = log .. line .. '\n'
    810  end
    811  log = log .. ('-'):rep(78) .. '\n'
    812  if is_ci and ci_rename then
    813    os.rename(logfile, logfile .. '.displayed')
    814  end
    815  return log
    816 end
    817 
    818 --- @param path string
    819 --- @return boolean?
    820 function M.mkdir(path)
    821  -- 493 is 0755 in decimal
    822  return (uv.fs_mkdir(path, 493))
    823 end
    824 
    825 --- @param expected any[]
    826 --- @param received any[]
    827 --- @param kind string
    828 --- @return any
    829 function M.expect_events(expected, received, kind)
    830  if not pcall(M.eq, expected, received) then
    831    local msg = 'unexpected ' .. kind .. ' received.\n\n'
    832 
    833    msg = msg .. 'received events:\n'
    834    for _, e in ipairs(received) do
    835      msg = msg .. '  ' .. vim.inspect(e) .. ';\n'
    836    end
    837    msg = msg .. '\nexpected events:\n'
    838    for _, e in ipairs(expected) do
    839      msg = msg .. '  ' .. vim.inspect(e) .. ';\n'
    840    end
    841    M.fail(msg)
    842  end
    843  return received
    844 end
    845 
    846 --- @param cond boolean
    847 --- @param reason? string
    848 --- @return boolean
    849 function M.skip(cond, reason)
    850  if cond then
    851    --- @type fun(reason: string)
    852    local pending = getfenv(2).pending
    853    pending(reason or 'FIXME')
    854    return true
    855  end
    856  return false
    857 end
    858 
    859 -- Calls pending() and returns `true` if the system is too slow to
    860 -- run fragile or expensive tests. Else returns `false`.
    861 function M.skip_fragile(pending_fn, cond)
    862  if pending_fn == nil or type(pending_fn) ~= type(function() end) then
    863    error('invalid pending_fn')
    864  end
    865  if cond then
    866    pending_fn('skipped (test is fragile on this system)', function() end)
    867    return true
    868  elseif os.getenv('TEST_SKIP_FRAGILE') then
    869    pending_fn('skipped (TEST_SKIP_FRAGILE)', function() end)
    870    return true
    871  end
    872  return false
    873 end
    874 
    875 function M.translations_enabled()
    876  return M.paths.translations_enabled
    877 end
    878 
    879 return M