mirror of
https://github.com/neovim/neovim.git
synced 2026-05-06 08:26:45 -04:00
0a9016689e
Problem: When ASAN detects an error in a child process, the process may be killed by test teardown before ASAN finishes writing its log file. This results in truncated logs with no stack trace. Also, the sanitizer log content was only written to stdout (which the test runner may not display) and not included in the test_assert error message. Solution: - Poll (up to 2s) for the ASAN "SUMMARY" line before reading, so the crashing process has time to finish writing. - Include full log content in the test_assert error message, so it appears in CI output regardless of stdout handling. - Warn when the log appears truncated (no SUMMARY line found).
901 lines
25 KiB
Lua
901 lines
25 KiB
Lua
local test_assert = require('test.assert')
|
|
---@type test.harness
|
|
local harness = require('test.harness')
|
|
local uv = vim.uv
|
|
local Paths = require('test.cmakeconfig.paths')
|
|
|
|
--- Functions executing in the context of the test runner (not the current nvim test session).
|
|
--- @class test.testutil
|
|
local M = {
|
|
paths = Paths,
|
|
}
|
|
|
|
--- @param path string
|
|
--- @return boolean
|
|
function M.isdir(path)
|
|
if not path then
|
|
return false
|
|
end
|
|
local stat = uv.fs_stat(path)
|
|
if not stat then
|
|
return false
|
|
end
|
|
return stat.type == 'directory'
|
|
end
|
|
|
|
--- (Only on Windows) Replaces yucky "\\" slashes with delicious "/" slashes in a string, or all
|
|
--- string values in a table (recursively).
|
|
---
|
|
--- @generic T: string|table
|
|
--- @param obj T
|
|
--- @return T|nil
|
|
function M.fix_slashes(obj)
|
|
if not M.is_os('win') then
|
|
return obj
|
|
end
|
|
if type(obj) == 'string' then
|
|
local ret = string.gsub(obj, '\\', '/')
|
|
return ret
|
|
elseif type(obj) == 'table' then
|
|
--- @cast obj table<any,any>
|
|
local ret = {} --- @type table<any,any>
|
|
for k, v in pairs(obj) do
|
|
ret[k] = M.fix_slashes(v)
|
|
end
|
|
return ret
|
|
end
|
|
assert(false, 'expected string or table of strings, got ' .. type(obj))
|
|
end
|
|
|
|
--- @param ... string|string[]
|
|
--- @return string[]
|
|
function M.argss_to_cmd(...)
|
|
local cmd = {} --- @type string[]
|
|
for i = 1, select('#', ...) do
|
|
local arg = select(i, ...)
|
|
if type(arg) == 'string' then
|
|
cmd[#cmd + 1] = arg
|
|
else
|
|
--- @cast arg string[]
|
|
for _, subarg in ipairs(arg) do
|
|
cmd[#cmd + 1] = subarg
|
|
end
|
|
end
|
|
end
|
|
return cmd
|
|
end
|
|
|
|
--- Calls fn() until it succeeds, up to `max` times or until `max_ms`
|
|
--- milliseconds have passed.
|
|
--- @param max integer?
|
|
--- @param max_ms integer?
|
|
--- @param fn function
|
|
--- @return any
|
|
function M.retry(max, max_ms, fn)
|
|
assert(max == nil or max > 0)
|
|
assert(max_ms == nil or max_ms > 0)
|
|
local tries = 1
|
|
local timeout = (max_ms and max_ms or 10000)
|
|
local start_time = uv.now()
|
|
while true do
|
|
--- @type boolean, any
|
|
local status, result = pcall(fn)
|
|
if status then
|
|
return result
|
|
end
|
|
uv.update_time() -- Update cached value of luv.now() (libuv: uv_now()).
|
|
if (max and tries >= max) or (uv.now() - start_time > timeout) then
|
|
error(string.format('retry() attempts: %d\n%s', tries, tostring(result)), 2)
|
|
end
|
|
tries = tries + 1
|
|
uv.sleep(20) -- Avoid hot loop...
|
|
end
|
|
end
|
|
|
|
local check_logs_useless_lines = {
|
|
['Warning: noted but unhandled ioctl'] = 1,
|
|
['could cause spurious value errors to appear'] = 2,
|
|
['See README_MISSING_SYSCALL_OR_IOCTL for guidance'] = 3,
|
|
}
|
|
|
|
function M.eq(expected, actual, context)
|
|
return test_assert.eq(expected, actual, context)
|
|
end
|
|
function M.neq(expected, actual, context)
|
|
return test_assert.neq(expected, actual, context)
|
|
end
|
|
|
|
--- Compare paths after resolving symlinks with realpath.
|
|
function M.eq_paths(expected, actual, context)
|
|
return M.eq(uv.fs_realpath(expected), uv.fs_realpath(actual), context)
|
|
end
|
|
|
|
--- Asserts that `cond` is true, or prints a message.
|
|
---
|
|
--- @param cond (boolean) expression to assert
|
|
--- @param expected (any) description of expected result
|
|
--- @param actual (any) description of actual result
|
|
function M.ok(cond, expected, actual)
|
|
assert(
|
|
(not expected and not actual) or (expected and actual),
|
|
'if "expected" is given, "actual" is also required'
|
|
)
|
|
local msg = expected and ('expected %s, got: %s'):format(expected, tostring(actual)) or nil
|
|
return assert(cond, msg)
|
|
end
|
|
|
|
--- @param pat string
|
|
--- @param actual string
|
|
--- @param plain boolean? (default: false)
|
|
--- @return boolean
|
|
function M.matches(pat, actual, plain)
|
|
assert(pat and pat ~= '', 'pat must be a non-empty string')
|
|
assert(plain == nil or type(plain) == 'boolean', 'plain must be nil or boolean')
|
|
if nil ~= string.find(actual, pat, 1, plain) then
|
|
return true
|
|
end
|
|
error(('Pattern does not match.\nPattern:\n%s\nActual:\n%s'):format(pat, actual))
|
|
end
|
|
|
|
--- @param pat string
|
|
--- @param actual string
|
|
--- @param plain boolean? (default: false)
|
|
--- @return boolean
|
|
function M.not_matches(pat, actual, plain)
|
|
assert(pat and pat ~= '', 'pat must be a non-empty string')
|
|
if nil == string.find(actual, pat, 1, plain) then
|
|
return true
|
|
end
|
|
error(('Pattern does match.\nPattern:\n%s\nActual:\n%s'):format(pat, actual))
|
|
end
|
|
|
|
--- Asserts that `pat` matches (or *not* if inverse=true) any text in the tail of `logfile`.
|
|
---
|
|
--- Matches are not restricted to a single line.
|
|
---
|
|
--- Retries for 1 second in case of filesystem delay.
|
|
---
|
|
---@param pat (string) Lua pattern to match text in the log file
|
|
---@param logfile? (string) Full path to log file (default=$NVIM_LOG_FILE)
|
|
---@param nrlines? (number) Search up to this many log lines (default 10)
|
|
---@param inverse? (boolean) Assert that the pattern does NOT match.
|
|
function M.assert_log(pat, logfile, nrlines, inverse)
|
|
logfile = logfile or os.getenv('NVIM_LOG_FILE') or 'nvim.log'
|
|
assert(logfile ~= nil, 'no logfile')
|
|
nrlines = nrlines or 10
|
|
|
|
M.retry(nil, 1000, function()
|
|
local lines = M.read_file_list(logfile, -nrlines) or {}
|
|
local text = table.concat(lines, '\n')
|
|
local ismatch = not not text:match(pat)
|
|
if (ismatch and inverse) or not (ismatch or inverse) then
|
|
local msg = string.format(
|
|
'Pattern %s %sfound in log (last %d lines): %q:\n%s',
|
|
vim.inspect(pat),
|
|
(inverse and '' or 'not '),
|
|
nrlines,
|
|
logfile,
|
|
vim.text.indent(4, text)
|
|
)
|
|
error(msg)
|
|
end
|
|
end)
|
|
end
|
|
|
|
--- Asserts that `pat` does NOT match any line in the tail of `logfile`.
|
|
---
|
|
--- @see assert_log
|
|
--- @param pat (string) Lua pattern to match lines in the log file
|
|
--- @param logfile? (string) Full path to log file (default=$NVIM_LOG_FILE)
|
|
--- @param nrlines? (number) Search up to this many log lines
|
|
function M.assert_nolog(pat, logfile, nrlines)
|
|
return M.assert_log(pat, logfile, nrlines, true)
|
|
end
|
|
|
|
--- @param fn fun(...): any
|
|
--- @param ... any
|
|
--- @return boolean, any
|
|
function M.pcall(fn, ...)
|
|
assert(type(fn) == 'function')
|
|
local status, rv = pcall(fn, ...)
|
|
if status then
|
|
return status, rv
|
|
end
|
|
|
|
-- From:
|
|
-- C:/long/path/foo.lua:186: Expected string, got number
|
|
-- to:
|
|
-- .../foo.lua:0: Expected string, got number
|
|
local errmsg = tostring(rv)
|
|
:gsub('([%s<])vim[/\\]([^%s:/\\]+):%d+', '%1\xffvim\xff%2:0')
|
|
:gsub('[^%s<]-[/\\]([^%s:/\\]+):%d+', '.../%1:0')
|
|
:gsub('\xffvim\xff', 'vim/')
|
|
|
|
-- Scrub numbers in paths/stacktraces:
|
|
-- shared.lua:0: in function 'gsplit'
|
|
-- shared.lua:0: in function <shared.lua:0>'
|
|
errmsg = errmsg:gsub('([^%s].lua):%d+', '%1:0')
|
|
-- [string "<nvim>"]:0:
|
|
-- [string ":lua"]:0:
|
|
-- [string ":luado"]:0:
|
|
errmsg = errmsg:gsub('(%[string "[^"]+"%]):%d+', '%1:0')
|
|
|
|
-- Scrub tab chars:
|
|
errmsg = errmsg:gsub('\t', ' ')
|
|
-- In Lua 5.1, we sometimes get a "(tail call): ?" on the last line.
|
|
-- We remove this so that the tests are not lua dependent.
|
|
errmsg = errmsg:gsub('%s*%(tail call%): %?', '')
|
|
|
|
return status, errmsg
|
|
end
|
|
|
|
-- Invokes `fn` and returns the error string (with truncated paths), or raises
|
|
-- an error if `fn` succeeds.
|
|
--
|
|
-- Replaces line/column numbers with zero:
|
|
-- shared.lua:0: in function 'gsplit'
|
|
-- shared.lua:0: in function <shared.lua:0>'
|
|
--
|
|
-- Usage:
|
|
-- -- Match exact string.
|
|
-- eq('e', pcall_err(function(a, b) error('e') end, 'arg1', 'arg2'))
|
|
-- -- Match Lua pattern.
|
|
-- matches('e[or]+$', pcall_err(function(a, b) error('some error') end, 'arg1', 'arg2'))
|
|
--
|
|
--- @param fn function
|
|
--- @return string
|
|
function M.pcall_err_withfile(fn, ...)
|
|
assert(type(fn) == 'function')
|
|
local status, rv = M.pcall(fn, ...)
|
|
if status == true then
|
|
error('expected failure, but got success')
|
|
end
|
|
return rv
|
|
end
|
|
|
|
--- @param fn function
|
|
--- @param ... any
|
|
--- @return string
|
|
function M.pcall_err_withtrace(fn, ...)
|
|
local errmsg = M.pcall_err_withfile(fn, ...)
|
|
|
|
return (
|
|
errmsg
|
|
:gsub('^%.%.%./testnvim%.lua:0: ', '')
|
|
:gsub('^Lua:- ', '')
|
|
:gsub('^%[string "<nvim>"%]:0: ', '')
|
|
)
|
|
end
|
|
|
|
--- @param fn function
|
|
--- @param ... any
|
|
--- @return string
|
|
function M.pcall_err(fn, ...)
|
|
return M.remove_trace(M.pcall_err_withtrace(fn, ...))
|
|
end
|
|
|
|
--- @param s string
|
|
--- @return string
|
|
function M.remove_trace(s)
|
|
return (s:gsub('\n%s*stack traceback:.*', ''))
|
|
end
|
|
|
|
-- initial_path: directory to recurse into
|
|
-- re: include pattern (string)
|
|
-- exc_re: exclude pattern(s) (string or table)
|
|
function M.glob(initial_path, re, exc_re)
|
|
exc_re = type(exc_re) == 'table' and exc_re or { exc_re }
|
|
local paths_to_check = { initial_path } --- @type string[]
|
|
local ret = {} --- @type string[]
|
|
local checked_files = {} --- @type table<string,true>
|
|
local function is_excluded(path)
|
|
for _, pat in pairs(exc_re) do
|
|
if path:match(pat) then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
if is_excluded(initial_path) then
|
|
return ret
|
|
end
|
|
while #paths_to_check > 0 do
|
|
local cur_path = paths_to_check[#paths_to_check]
|
|
paths_to_check[#paths_to_check] = nil
|
|
for e in vim.fs.dir(cur_path) do
|
|
local full_path = cur_path .. '/' .. e
|
|
local checked_path = full_path:sub(#initial_path + 1)
|
|
if (not is_excluded(checked_path)) and e:sub(1, 1) ~= '.' then
|
|
local stat = uv.fs_stat(full_path)
|
|
if stat then
|
|
local check_key = stat.dev .. ':' .. tostring(stat.ino)
|
|
if not checked_files[check_key] then
|
|
checked_files[check_key] = true
|
|
if stat.type == 'directory' then
|
|
paths_to_check[#paths_to_check + 1] = full_path
|
|
elseif not re or checked_path:match(re) then
|
|
ret[#ret + 1] = full_path
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return ret
|
|
end
|
|
|
|
--- Reads sanitizer/valgrind log file lines, filtering out useless warnings.
|
|
--- Waits briefly for the ASAN "SUMMARY" line so we don't read a truncated report
|
|
--- (the crashing process may still be writing when we check).
|
|
local function read_sanitizer_log(file)
|
|
local lines = {} --- @type string[]
|
|
local has_summary = false
|
|
-- Poll for up to 2 seconds for the log to be complete.
|
|
for _ = 1, 20 do
|
|
lines = {}
|
|
local warning_line = 0
|
|
local fd = assert(io.open(file))
|
|
for line in fd:lines() do
|
|
local cur_warning_line = check_logs_useless_lines[line]
|
|
if cur_warning_line == warning_line + 1 then
|
|
warning_line = cur_warning_line
|
|
else
|
|
lines[#lines + 1] = line
|
|
end
|
|
if line:find('SUMMARY') then
|
|
has_summary = true
|
|
end
|
|
end
|
|
fd:close()
|
|
if has_summary or #lines == 0 then
|
|
break
|
|
end
|
|
uv.sleep(100)
|
|
end
|
|
if not has_summary and #lines > 0 then
|
|
lines[#lines + 1] = '(WARNING: sanitizer log may be truncated, no SUMMARY line found)'
|
|
end
|
|
return lines
|
|
end
|
|
|
|
function M.check_logs()
|
|
local log_dir = os.getenv('LOG_DIR')
|
|
local runtime_errors = {} --- @type string[]
|
|
local runtime_errors_detail = {} --- @type string[]
|
|
if log_dir and M.isdir(log_dir) then
|
|
for tail in vim.fs.dir(log_dir) do
|
|
if tail:sub(1, 30) == 'valgrind-' or tail:find('san%.') then
|
|
local file = ('%s/%s'):format(log_dir, tail)
|
|
local lines = read_sanitizer_log(file)
|
|
if #lines > 0 then
|
|
local start_msg = ('%s File %s %s'):format(('='):rep(20), file, ('='):rep(20))
|
|
local end_msg = select(1, start_msg:gsub('.', '='))
|
|
local lines_str = ('= %s'):format(table.concat(lines, '\n= '))
|
|
local detail = ('%s\n%s\n%s'):format(start_msg, lines_str, end_msg)
|
|
--- @type boolean?, file*?
|
|
local status, f
|
|
if os.getenv('SYMBOLIZER') then
|
|
status, f = pcall(M.repeated_read_cmd, os.getenv('SYMBOLIZER'), '-l', file)
|
|
end
|
|
local out = io.stdout
|
|
out:write(start_msg .. '\n')
|
|
if status then
|
|
assert(f)
|
|
for line in f:lines() do
|
|
out:write(('= %s\n'):format(line))
|
|
end
|
|
f:close()
|
|
else
|
|
out:write(lines_str .. '\n')
|
|
end
|
|
out:write(end_msg .. '\n')
|
|
table.insert(runtime_errors, file)
|
|
table.insert(runtime_errors_detail, detail)
|
|
end
|
|
os.remove(file)
|
|
end
|
|
end
|
|
end
|
|
test_assert(
|
|
0 == #runtime_errors,
|
|
string.format(
|
|
'Found runtime errors in logfile(s):\n%s',
|
|
table.concat(runtime_errors_detail, '\n')
|
|
)
|
|
)
|
|
end
|
|
|
|
local sysname = uv.os_uname().sysname:lower()
|
|
|
|
--- @param s 'win'|'mac'|'linux'|'freebsd'|'openbsd'|'bsd'
|
|
--- @return boolean
|
|
function M.is_os(s)
|
|
if
|
|
not (s == 'win' or s == 'mac' or s == 'linux' or s == 'freebsd' or s == 'openbsd' or s == 'bsd')
|
|
then
|
|
error('unknown platform: ' .. tostring(s))
|
|
end
|
|
return not not (
|
|
(s == 'win' and (sysname:find('windows') or sysname:find('mingw')))
|
|
or (s == 'mac' and sysname == 'darwin')
|
|
or (s == 'linux' and sysname == 'linux')
|
|
or (s == 'freebsd' and sysname == 'freebsd')
|
|
or (s == 'openbsd' and sysname == 'openbsd')
|
|
or (s == 'bsd' and sysname:find('bsd'))
|
|
)
|
|
end
|
|
|
|
local architecture = uv.os_uname().machine
|
|
|
|
--- @param s 'x86_64'|'arm64'|'s390x'
|
|
--- @return boolean
|
|
function M.is_arch(s)
|
|
if not (s == 'x86_64' or s == 'arm64' or s == 's390x') then
|
|
error('unknown architecture: ' .. tostring(s))
|
|
end
|
|
return s == architecture
|
|
end
|
|
|
|
function M.is_asan()
|
|
return M.paths.is_asan
|
|
end
|
|
|
|
function M.is_zig_build()
|
|
return M.paths.is_zig_build
|
|
end
|
|
|
|
local tmpname_id = 0
|
|
local tmpdir = os.getenv('TMPDIR') or os.getenv('TEMP')
|
|
local tmpdir_is_local = not not (tmpdir and tmpdir:find('Xtest'))
|
|
|
|
local function get_tmpname()
|
|
if tmpdir_is_local then
|
|
-- Cannot control os.tmpname() dir, so hack our own tmpname() impl.
|
|
tmpname_id = tmpname_id + 1
|
|
-- "…/Xtest_tmpdir/T42.7"
|
|
return ('%s/%s.%d'):format(tmpdir, (_G._nvim_test_id or 'nvim-test'), tmpname_id)
|
|
end
|
|
|
|
local fname = os.tmpname()
|
|
|
|
if M.is_os('win') and fname:sub(1, 2) == '\\s' then
|
|
-- In Windows tmpname() returns a filename starting with
|
|
-- special sequence \s, prepend $TEMP path
|
|
return tmpdir .. fname
|
|
elseif M.is_os('mac') and fname:match('^/tmp') then
|
|
-- In OS X /tmp links to /private/tmp
|
|
return '/private' .. fname
|
|
end
|
|
return fname
|
|
end
|
|
|
|
--- Generates a unique filepath for use by tests, in a test-specific "…/Xtest_tmpdir/T42.7"
|
|
--- directory (which is cleaned up by the test runner).
|
|
---
|
|
--- @param create? boolean (default true) Create the file.
|
|
--- @return string
|
|
function M.tmpname(create)
|
|
local fname = get_tmpname()
|
|
os.remove(fname)
|
|
if create ~= false then
|
|
assert(io.open(fname, 'w')):close()
|
|
end
|
|
return fname
|
|
end
|
|
|
|
local function deps_prefix()
|
|
local env = os.getenv('DEPS_PREFIX')
|
|
return (env and env ~= '') and env or '.deps/usr'
|
|
end
|
|
|
|
local tests_skipped = 0
|
|
|
|
function M.check_cores(app, force) -- luacheck: ignore
|
|
-- Temporary workaround: skip core check as it interferes with CI.
|
|
if true then
|
|
return
|
|
end
|
|
app = app or 'build/bin/nvim' -- luacheck: ignore
|
|
--- @type string, string?, string[]
|
|
local initial_path, re, exc_re
|
|
local gdb_db_cmd =
|
|
'gdb -n -batch -ex "thread apply all bt full" "$_NVIM_TEST_APP" -c "$_NVIM_TEST_CORE"'
|
|
local lldb_db_cmd = 'lldb -Q -o "bt all" -f "$_NVIM_TEST_APP" -c "$_NVIM_TEST_CORE"'
|
|
local random_skip = false
|
|
-- Workspace-local $TMPDIR, scrubbed and pattern-escaped.
|
|
-- "./Xtest-tmpdir/" => "Xtest%-tmpdir"
|
|
local local_tmpdir = nil
|
|
if tmpdir_is_local and tmpdir then
|
|
local_tmpdir =
|
|
vim.pesc(vim.fs.relpath(assert(vim.uv.cwd()), tmpdir):gsub('^[ ./]+', ''):gsub('%/+$', ''))
|
|
end
|
|
|
|
local db_cmd --- @type string
|
|
local test_glob_dir = os.getenv('NVIM_TEST_CORE_GLOB_DIRECTORY')
|
|
if test_glob_dir and test_glob_dir ~= '' then
|
|
initial_path = test_glob_dir
|
|
re = os.getenv('NVIM_TEST_CORE_GLOB_RE')
|
|
exc_re = { os.getenv('NVIM_TEST_CORE_EXC_RE'), local_tmpdir }
|
|
db_cmd = os.getenv('NVIM_TEST_CORE_DB_CMD') or gdb_db_cmd
|
|
random_skip = os.getenv('NVIM_TEST_CORE_RANDOM_SKIP') ~= ''
|
|
elseif M.is_os('mac') then
|
|
initial_path = '/cores'
|
|
re = nil
|
|
exc_re = { local_tmpdir }
|
|
db_cmd = lldb_db_cmd
|
|
else
|
|
initial_path = '.'
|
|
if M.is_os('freebsd') then
|
|
re = '/nvim.core$'
|
|
else
|
|
re = '/core[^/]*$'
|
|
end
|
|
exc_re = { '^/%.deps$', '^/%' .. deps_prefix() .. '$', local_tmpdir, '^/%node_modules$' }
|
|
db_cmd = gdb_db_cmd
|
|
random_skip = true
|
|
end
|
|
-- Finding cores takes too much time on linux
|
|
if not force and random_skip and math.random() < 0.9 then
|
|
tests_skipped = tests_skipped + 1
|
|
return
|
|
end
|
|
local cores = M.glob(initial_path, re, exc_re)
|
|
local found_cores = 0
|
|
local out = io.stdout
|
|
for _, core in ipairs(cores) do
|
|
local len = 80 - #core - #'Core file ' - 2
|
|
local esigns = ('='):rep(len / 2)
|
|
out:write(('\n%s Core file %s %s\n'):format(esigns, core, esigns))
|
|
out:flush()
|
|
os.execute(db_cmd:gsub('%$_NVIM_TEST_APP', app):gsub('%$_NVIM_TEST_CORE', core) .. ' 2>&1')
|
|
out:write('\n')
|
|
found_cores = found_cores + 1
|
|
os.remove(core)
|
|
end
|
|
if found_cores ~= 0 then
|
|
out:write(('\nTests covered by this check: %u\n'):format(tests_skipped + 1))
|
|
end
|
|
tests_skipped = 0
|
|
if found_cores > 0 then
|
|
error('crash detected (see above)')
|
|
end
|
|
end
|
|
|
|
--- @return string?
|
|
function M.repeated_read_cmd(...)
|
|
local cmd = M.argss_to_cmd(...)
|
|
local data = {}
|
|
local got_code = nil
|
|
local stdout = assert(vim.uv.new_pipe(false))
|
|
local handle = assert(
|
|
vim.uv.spawn(
|
|
cmd[1],
|
|
{ args = vim.list_slice(cmd, 2), stdio = { nil, stdout, 2 }, hide = true },
|
|
function(code, _signal)
|
|
got_code = code
|
|
end
|
|
)
|
|
)
|
|
stdout:read_start(function(err, chunk)
|
|
if err or chunk == nil then
|
|
stdout:read_stop()
|
|
stdout:close()
|
|
else
|
|
table.insert(data, chunk)
|
|
end
|
|
end)
|
|
|
|
while not stdout:is_closing() or got_code == nil do
|
|
vim.uv.run('once')
|
|
end
|
|
|
|
if got_code ~= 0 then
|
|
error('command ' .. vim.inspect(cmd) .. 'unexpectedly exited with status ' .. got_code)
|
|
end
|
|
handle:close()
|
|
return table.concat(data)
|
|
end
|
|
|
|
--- @generic T
|
|
--- @param orig T
|
|
--- @return T
|
|
function M.shallowcopy(orig)
|
|
if type(orig) ~= 'table' then
|
|
return orig
|
|
end
|
|
--- @cast orig table<any,any>
|
|
local copy = {} --- @type table<any,any>
|
|
for orig_key, orig_value in pairs(orig) do
|
|
copy[orig_key] = orig_value
|
|
end
|
|
return copy
|
|
end
|
|
|
|
--- @param d1 table<any,any>
|
|
--- @param d2 table<any,any>
|
|
--- @return table<any,any>
|
|
function M.mergedicts_copy(d1, d2)
|
|
local ret = M.shallowcopy(d1)
|
|
for k, v in pairs(d2) do
|
|
if d2[k] == vim.NIL then
|
|
ret[k] = nil
|
|
elseif type(d1[k]) == 'table' and type(v) == 'table' then
|
|
ret[k] = M.mergedicts_copy(d1[k], v)
|
|
else
|
|
ret[k] = v
|
|
end
|
|
end
|
|
return ret
|
|
end
|
|
|
|
--- dictdiff: find a diff so that mergedicts_copy(d1, diff) is equal to d2
|
|
---
|
|
--- Note: does not do copies of d2 values used.
|
|
--- @param d1 table<any,any>
|
|
--- @param d2 table<any,any>
|
|
function M.dictdiff(d1, d2)
|
|
local ret = {} --- @type table<any,any>
|
|
local hasdiff = false
|
|
for k, v in pairs(d1) do
|
|
if d2[k] == nil then
|
|
hasdiff = true
|
|
ret[k] = vim.NIL
|
|
elseif type(v) == type(d2[k]) then
|
|
if type(v) == 'table' then
|
|
local subdiff = M.dictdiff(v, d2[k])
|
|
if subdiff ~= nil then
|
|
hasdiff = true
|
|
ret[k] = subdiff
|
|
end
|
|
elseif v ~= d2[k] then
|
|
ret[k] = d2[k]
|
|
hasdiff = true
|
|
end
|
|
else
|
|
ret[k] = d2[k]
|
|
hasdiff = true
|
|
end
|
|
end
|
|
local shallowcopy = M.shallowcopy
|
|
for k, v in pairs(d2) do
|
|
if d1[k] == nil then
|
|
ret[k] = shallowcopy(v)
|
|
hasdiff = true
|
|
end
|
|
end
|
|
if hasdiff then
|
|
return ret
|
|
else
|
|
return nil
|
|
end
|
|
end
|
|
|
|
-- Concat list-like tables.
|
|
function M.concat_tables(...)
|
|
local ret = {} --- @type table<any,any>
|
|
for i = 1, select('#', ...) do
|
|
--- @type table<any,any>
|
|
local tbl = select(i, ...)
|
|
if tbl then
|
|
for _, v in ipairs(tbl) do
|
|
ret[#ret + 1] = v
|
|
end
|
|
end
|
|
end
|
|
return ret
|
|
end
|
|
|
|
--- Get all permutations of an array.
|
|
---
|
|
--- @param arr any[]
|
|
--- @return any[][]
|
|
function M.permutations(arr)
|
|
local res = {} --- @type any[][]
|
|
--- @param a any[]
|
|
--- @param n integer
|
|
local function gen(a, n)
|
|
if n == 0 then
|
|
res[#res + 1] = M.shallowcopy(a)
|
|
return
|
|
end
|
|
for i = 1, n do
|
|
a[n], a[i] = a[i], a[n]
|
|
gen(a, n - 1)
|
|
a[n], a[i] = a[i], a[n]
|
|
end
|
|
end
|
|
gen(M.shallowcopy(arr), #arr)
|
|
return res
|
|
end
|
|
|
|
--- @param str string
|
|
--- @param leave_indent? integer
|
|
--- @return string
|
|
function M.dedent(str, leave_indent)
|
|
-- 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)
|
|
ch = tonumber(ch)
|
|
return (20 <= ch and ch < 127) and ('%c'):format(ch) or ch
|
|
end
|
|
|
|
--- @param str string
|
|
--- @return string
|
|
function M.hexdump(str)
|
|
local len = string.len(str)
|
|
local dump = ''
|
|
local hex = ''
|
|
local asc = ''
|
|
|
|
for i = 1, len do
|
|
if 1 == i % 8 then
|
|
dump = dump .. hex .. asc .. '\n'
|
|
hex = string.format('%04x: ', i - 1)
|
|
asc = ''
|
|
end
|
|
|
|
local ord = string.byte(str, i)
|
|
hex = hex .. string.format('%02x ', ord)
|
|
if ord >= 32 and ord <= 126 then
|
|
asc = asc .. string.char(ord)
|
|
else
|
|
asc = asc .. '.'
|
|
end
|
|
end
|
|
|
|
return dump .. hex .. string.rep(' ', 8 - len % 8) .. asc
|
|
end
|
|
|
|
--- Reads text lines from `filename` into a table.
|
|
--- @param filename string path to file
|
|
--- @param start? integer start line (1-indexed), negative means "lines before end" (tail)
|
|
--- @return string[]?
|
|
function M.read_file_list(filename, start)
|
|
local lnum = (start ~= nil and type(start) == 'number') and start or 1
|
|
local tail = (lnum < 0)
|
|
local maxlines = tail and math.abs(lnum) or nil
|
|
local file = io.open(filename, 'r')
|
|
if not file then
|
|
return nil
|
|
end
|
|
|
|
-- There is no need to read more than the last 2MB of the log file, so seek
|
|
-- to that.
|
|
local file_size = file:seek('end')
|
|
local offset = file_size - 2000000
|
|
if offset < 0 then
|
|
offset = 0
|
|
end
|
|
file:seek('set', offset)
|
|
|
|
local lines = {}
|
|
local i = 1
|
|
local line = file:read('*l')
|
|
while line ~= nil do
|
|
if i >= start then
|
|
table.insert(lines, line)
|
|
if #lines > maxlines then
|
|
table.remove(lines, 1)
|
|
end
|
|
end
|
|
i = i + 1
|
|
line = file:read('*l')
|
|
end
|
|
file:close()
|
|
return lines
|
|
end
|
|
|
|
--- Reads the entire contents of `filename` into a string.
|
|
--- @param filename string
|
|
--- @return string?
|
|
function M.read_file(filename)
|
|
local file = io.open(filename, 'r')
|
|
if not file then
|
|
return nil
|
|
end
|
|
local ret = file:read('*a')
|
|
file:close()
|
|
return ret
|
|
end
|
|
|
|
-- Dedent the given text and write it to the file name.
|
|
function M.write_file(name, text, no_dedent, append)
|
|
local file = assert(io.open(name, (append and 'a' or 'w')))
|
|
if type(text) == 'table' then
|
|
-- Byte blob
|
|
--- @type string[]
|
|
local bytes = text
|
|
text = ''
|
|
for _, char in ipairs(bytes) do
|
|
text = ('%s%c'):format(text, char)
|
|
end
|
|
elseif not no_dedent then
|
|
text = M.dedent(text)
|
|
end
|
|
file:write(text)
|
|
file:flush()
|
|
file:close()
|
|
end
|
|
|
|
--- @param name? 'github'
|
|
--- @return boolean
|
|
function M.is_ci(name)
|
|
return harness.is_ci(name)
|
|
end
|
|
|
|
-- Gets the (tail) contents of `logfile`.
|
|
-- Also moves the file to "${NVIM_LOG_FILE}.displayed" on CI environments.
|
|
function M.read_nvim_log(logfile, ci_rename)
|
|
logfile = logfile or os.getenv('NVIM_LOG_FILE') or 'nvim.log'
|
|
local log = harness.read_nvim_log(logfile, ci_rename)
|
|
assert(log, ('logfile not found: %q'):format(logfile))
|
|
return log
|
|
end
|
|
|
|
--- @param path string
|
|
--- @return boolean?
|
|
function M.mkdir(path)
|
|
-- 493 is 0755 in decimal
|
|
return (uv.fs_mkdir(path, 493))
|
|
end
|
|
|
|
--- @param expected any[]
|
|
--- @param received any[]
|
|
--- @param kind string
|
|
--- @return any
|
|
function M.expect_events(expected, received, kind)
|
|
if not pcall(M.eq, expected, received) then
|
|
local msg = 'unexpected ' .. kind .. ' received.\n\n'
|
|
|
|
msg = msg .. 'received events:\n'
|
|
for _, e in ipairs(received) do
|
|
msg = msg .. ' ' .. vim.inspect(e) .. ';\n'
|
|
end
|
|
msg = msg .. '\nexpected events:\n'
|
|
for _, e in ipairs(expected) do
|
|
msg = msg .. ' ' .. vim.inspect(e) .. ';\n'
|
|
end
|
|
error(msg, 2)
|
|
end
|
|
return received
|
|
end
|
|
|
|
--- @param cond boolean
|
|
--- @param reason? string
|
|
--- @return boolean
|
|
function M.skip(cond, reason)
|
|
if cond then
|
|
--- @type fun(reason: string)
|
|
local pending = getfenv(2).pending
|
|
pending(reason or 'FIXME')
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Calls pending() and returns `true` if the system is too slow to
|
|
-- run fragile or expensive tests. Else returns `false`.
|
|
function M.skip_fragile(pending_fn, cond)
|
|
if pending_fn == nil or type(pending_fn) ~= type(function() end) then
|
|
error('invalid pending_fn')
|
|
end
|
|
if cond then
|
|
pending_fn('skipped (test is fragile on this system)', function() end)
|
|
return true
|
|
elseif os.getenv('TEST_SKIP_FRAGILE') then
|
|
pending_fn('skipped (TEST_SKIP_FRAGILE)', function() end)
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
function M.translations_enabled()
|
|
return M.paths.translations_enabled
|
|
end
|
|
|
|
return M
|