testutil.lua 24 KB

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