testutil.lua 23 KB

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