testutil.lua 23 KB

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