testutil.lua 23 KB

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