require('coxpcall') local luv = require('luv') local lfs = require('lfs') local global_helpers = require('test.helpers') -- nvim client: Found in .deps/usr/share/lua//nvim/ if "bundled". local Session = require('nvim.session') local TcpStream = require('nvim.tcp_stream') local SocketStream = require('nvim.socket_stream') local ChildProcessStream = require('nvim.child_process_stream') local Paths = require('test.config.paths') local check_cores = global_helpers.check_cores local check_logs = global_helpers.check_logs local dedent = global_helpers.dedent local eq = global_helpers.eq local expect_err = global_helpers.expect_err local filter = global_helpers.filter local map = global_helpers.map local matches = global_helpers.matches local near = global_helpers.near local neq = global_helpers.neq local ok = global_helpers.ok local read_file = global_helpers.read_file local sleep = global_helpers.sleep local table_flatten = global_helpers.table_flatten local write_file = global_helpers.write_file local start_dir = lfs.currentdir() -- XXX: NVIM_PROG takes precedence, QuickBuild sets it. local nvim_prog = ( os.getenv('NVIM_PROG') or os.getenv('NVIM_PRG') or Paths.test_build_dir .. '/bin/nvim' ) -- Default settings for the test session. local nvim_set = 'set shortmess+=I background=light noswapfile noautoindent' ..' laststatus=1 undodir=. directory=. viewdir=. backupdir=.' ..' belloff= noshowcmd noruler nomore' local nvim_argv = {nvim_prog, '-u', 'NONE', '-i', 'NONE', '-N', '--cmd', nvim_set, '--embed'} -- Directory containing nvim. local nvim_dir = nvim_prog:gsub("[/\\][^/\\]+$", "") if nvim_dir == nvim_prog then nvim_dir = "." end local mpack = require('mpack') local tmpname = global_helpers.tmpname local uname = global_helpers.uname local prepend_argv if os.getenv('VALGRIND') then local log_file = os.getenv('VALGRIND_LOG') or 'valgrind-%p.log' prepend_argv = {'valgrind', '-q', '--tool=memcheck', '--leak-check=yes', '--track-origins=yes', '--show-possibly-lost=no', '--suppressions=src/.valgrind.supp', '--log-file='..log_file} if os.getenv('GDB') then table.insert(prepend_argv, '--vgdb=yes') table.insert(prepend_argv, '--vgdb-error=0') end elseif os.getenv('GDB') then local gdbserver_port = '7777' if os.getenv('GDBSERVER_PORT') then gdbserver_port = os.getenv('GDBSERVER_PORT') end prepend_argv = {'gdbserver', 'localhost:'..gdbserver_port} end if prepend_argv then local new_nvim_argv = {} local len = #prepend_argv for i = 1, len do new_nvim_argv[i] = prepend_argv[i] end for i = 1, #nvim_argv do new_nvim_argv[i + len] = nvim_argv[i] end nvim_argv = new_nvim_argv end local session, loop_running, last_error local function get_session() return session end local function set_session(s, keep) if session and not keep then session:close() end session = s end local function request(method, ...) local status, rv = session:request(method, ...) if not status then if loop_running then last_error = rv[2] session:stop() else error(rv[2]) end end return rv end local function next_msg(timeout) return session:next_message(timeout and timeout or 10000) end local function expect_twostreams(msgs1, msgs2) local pos1, pos2 = 1, 1 while pos1 <= #msgs1 or pos2 <= #msgs2 do local msg = next_msg() if pos1 <= #msgs1 and pcall(eq, msgs1[pos1], msg) then pos1 = pos1 + 1 elseif pos2 <= #msgs2 then eq(msgs2[pos2], msg) pos2 = pos2 + 1 else -- already failed, but show the right error message eq(msgs1[pos1], msg) end end end -- Expects a sequence of next_msg() results. If multiple sequences are -- passed they are tried until one succeeds, in order of shortest to longest. local function expect_msg_seq(...) if select('#', ...) < 1 then error('need at least 1 argument') end local seqs = {...} table.sort(seqs, function(a, b) -- Sort ascending, by (shallow) length. return #a < #b end) local actual_seq = {} local final_error = '' local function cat_err(err1, err2) if err1 == nil then return err2 end return string.format('%s\n%s\n%s', err1, string.rep('=', 78), err2) end for anum = 1, #seqs do local expected_seq = seqs[anum] -- Collect enough messages to compare the next expected sequence. while #actual_seq < #expected_seq do local msg = next_msg(10000) -- Big timeout for ASAN/valgrind. if msg == nil then error(cat_err(final_error, string.format('got %d messages, expected %d', #actual_seq, #expected_seq))) end table.insert(actual_seq, msg) end local status, result = pcall(eq, expected_seq, actual_seq) if status then return result end final_error = cat_err(final_error, result) end error(final_error) end local function call_and_stop_on_error(lsession, ...) local status, result = copcall(...) -- luacheck: ignore if not status then lsession:stop() last_error = result return '' end return result end local function run_session(lsession, request_cb, notification_cb, setup_cb, timeout) local on_request, on_notification, on_setup if request_cb then function on_request(method, args) return call_and_stop_on_error(lsession, request_cb, method, args) end end if notification_cb then function on_notification(method, args) call_and_stop_on_error(lsession, notification_cb, method, args) end end if setup_cb then function on_setup() call_and_stop_on_error(lsession, setup_cb) end end loop_running = true session:run(on_request, on_notification, on_setup, timeout) loop_running = false if last_error then local err = last_error last_error = nil error(err) end end local function run(request_cb, notification_cb, setup_cb, timeout) run_session(session, request_cb, notification_cb, setup_cb, timeout) end local function stop() session:stop() end -- Executes an ex-command. VimL errors manifest as client (lua) errors, but -- v:errmsg will not be updated. local function nvim_command(cmd) request('nvim_command', cmd) end -- Evaluates a VimL expression. -- Fails on VimL error, but does not update v:errmsg. local function nvim_eval(expr) return request('nvim_eval', expr) end local os_name = (function() local name = nil return (function() if not name then if nvim_eval('has("win32")') == 1 then name = 'windows' elseif nvim_eval('has("macunix")') == 1 then name = 'osx' else name = 'unix' end end return name end) end)() local function iswin() return package.config:sub(1,1) == '\\' end -- Executes a VimL function. -- Fails on VimL error, but does not update v:errmsg. local function nvim_call(name, ...) return request('nvim_call_function', name, {...}) end -- Sends user input to Nvim. -- Does not fail on VimL error, but v:errmsg will be updated. local function nvim_feed(input) while #input > 0 do local written = request('nvim_input', input) input = input:sub(written + 1) end end local function feed(...) for _, v in ipairs({...}) do nvim_feed(dedent(v)) end end local function rawfeed(...) for _, v in ipairs({...}) do nvim_feed(dedent(v)) end end local function merge_args(...) local i = 1 local argv = {} for anum = 1,select('#', ...) do local args = select(anum, ...) if args then for _, arg in ipairs(args) do argv[i] = arg i = i + 1 end end end return argv end local function spawn(argv, merge, env) local child_stream = ChildProcessStream.spawn( merge and merge_args(prepend_argv, argv) or argv, env) return Session.new(child_stream) end -- Creates a new Session connected by domain socket (named pipe) or TCP. local function connect(file_or_address) local addr, port = string.match(file_or_address, "(.*):(%d+)") local stream = (addr and port) and TcpStream.open(addr, port) or SocketStream.open(file_or_address) return Session.new(stream) end -- Calls fn() until it succeeds, up to `max` times or until `max_ms` -- milliseconds have passed. local function retry(max, max_ms, fn) assert(max == nil or max > 0) assert(max_ms == nil or max_ms > 0) local tries = 1 local timeout = (max_ms and max_ms or 10000) local start_time = luv.now() while true do local status, result = pcall(fn) if status then return result end luv.update_time() -- Update cached value of luv.now() (libuv: uv_now()). if (max and tries >= max) or (luv.now() - start_time > timeout) then error("\nretry() attempts: "..tostring(tries).."\n"..tostring(result)) end tries = tries + 1 luv.sleep(20) -- Avoid hot loop... end end -- Starts a new global Nvim session. -- Parameters are interpreted as startup args, OR a map with these keys: -- args: Merged with the default `nvim_argv` set. -- env : Defines the environment of the new session. -- -- Example: -- clear('-e') -- clear({args={'-e'}, env={TERM=term}}) local function clear(...) local args = {unpack(nvim_argv)} local new_args local env = nil local opts = select(1, ...) local headless = true if type(opts) == 'table' then if opts.env then local env_tbl = {} for k, v in pairs(opts.env) do assert(type(k) == 'string') assert(type(v) == 'string') env_tbl[k] = v end for _, k in ipairs({ 'HOME', 'ASAN_OPTIONS', 'LD_LIBRARY_PATH', 'PATH', 'NVIM_LOG_FILE', 'NVIM_RPLUGIN_MANIFEST', }) do if not env_tbl[k] then env_tbl[k] = os.getenv(k) end end env = {} for k, v in pairs(env_tbl) do env[#env + 1] = k .. '=' .. v end end new_args = opts.args or {} if opts.headless == false then headless = false end else new_args = {...} end if headless then table.insert(args, '--headless') end for _, arg in ipairs(new_args) do table.insert(args, arg) end set_session(spawn(args, nil, env)) end local function insert(...) nvim_feed('i') for _, v in ipairs({...}) do local escaped = v:gsub('<', '') rawfeed(escaped) end nvim_feed('') end -- Executes an ex-command by user input. Because nvim_input() is used, VimL -- errors will not manifest as client (lua) errors. Use command() for that. local function feed_command(...) for _, v in ipairs({...}) do if v:sub(1, 1) ~= '/' then -- not a search command, prefix with colon nvim_feed(':') end nvim_feed(v:gsub('<', '')) nvim_feed('') end end local sourced_fnames = {} local function source(code) local fname = tmpname() write_file(fname, code) nvim_command('source '..fname) -- DO NOT REMOVE FILE HERE. -- do_source() has a habit of checking whether files are “same” by using inode -- and device IDs. If you run two source() calls in quick succession there is -- a good chance that underlying filesystem will reuse the inode, making files -- appear as “symlinks” to do_source when it checks FileIDs. With current -- setup linux machines (both QB, travis and mine(ZyX-I) with XFS) do reuse -- inodes, Mac OS machines (again, both QB and travis) do not. -- -- Files appearing as “symlinks” mean that both the first and the second -- source() calls will use same SID, which may fail some tests which check for -- exact numbers after `` in e.g. function names. sourced_fnames[#sourced_fnames + 1] = fname return fname end local function set_shell_powershell() source([[ set shell=powershell shellquote=( shellpipe=\| shellredir=> shellxquote= let &shellcmdflag = '-NoLogo -NoProfile -ExecutionPolicy RemoteSigned -Command Remove-Item -Force alias:sleep;' ]]) end local function nvim(method, ...) return request('nvim_'..method, ...) end local function ui(method, ...) return request('nvim_ui_'..method, ...) end local function nvim_async(method, ...) session:notify('nvim_'..method, ...) end local function buffer(method, ...) return request('nvim_buf_'..method, ...) end local function window(method, ...) return request('nvim_win_'..method, ...) end local function tabpage(method, ...) return request('nvim_tabpage_'..method, ...) end local function curbuf(method, ...) if not method then return nvim('get_current_buf') end return buffer(method, 0, ...) end local function wait() -- Execute 'nvim_eval' (a deferred function) to block -- until all pending input is processed. session:request('nvim_eval', '1') end local function curbuf_contents() wait() -- Before inspecting the buffer, process all input. return table.concat(curbuf('get_lines', 0, -1, true), '\n') end local function curwin(method, ...) if not method then return nvim('get_current_win') end return window(method, 0, ...) end local function curtab(method, ...) if not method then return nvim('get_current_tabpage') end return tabpage(method, 0, ...) end local function expect(contents) return eq(dedent(contents), curbuf_contents()) end local function expect_any(contents) contents = dedent(contents) return ok(nil ~= string.find(curbuf_contents(), contents, 1, true)) end local function do_rmdir(path) if lfs.attributes(path, 'mode') ~= 'directory' then return -- Don't complain. end for file in lfs.dir(path) do if file ~= '.' and file ~= '..' then local abspath = path..'/'..file if lfs.attributes(abspath, 'mode') == 'directory' then do_rmdir(abspath) -- recurse else local ret, err = os.remove(abspath) if not ret then if not session then error('os.remove: '..err) else -- Try Nvim delete(): it handles `readonly` attribute on Windows, -- and avoids Lua cross-version/platform incompatibilities. if -1 == nvim_call('delete', abspath) then local hint = (os_name() == 'windows' and ' (hint: try :%bwipeout! before rmdir())' or '') error('delete() failed'..hint..': '..abspath) end end end end end end local ret, err = lfs.rmdir(path) if not ret then error('lfs.rmdir('..path..'): '..err) end end local function rmdir(path) local ret, _ = pcall(do_rmdir, path) if not ret and os_name() == "windows" then -- Maybe "Permission denied"; try again after changing the nvim -- process to the top-level directory. nvim_command([[exe 'cd '.fnameescape(']]..start_dir.."')") ret, _ = pcall(do_rmdir, path) end -- During teardown, the nvim process may not exit quickly enough, then rmdir() -- will fail (on Windows). if not ret then -- Try again. sleep(1000) do_rmdir(path) end end local exc_exec = function(cmd) nvim_command(([[ try execute "%s" catch let g:__exception = v:exception endtry ]]):format(cmd:gsub('\n', '\\n'):gsub('[\\"]', '\\%0'))) local ret = nvim_eval('get(g:, "__exception", 0)') nvim_command('unlet! g:__exception') return ret end local function create_callindex(func) local table = {} setmetatable(table, { __index = function(tbl, arg1) local ret = function(...) return func(arg1, ...) end tbl[arg1] = ret return ret end, }) return table end -- Helper to skip tests. Returns true in Windows systems. -- pending_fn is pending() from busted local function pending_win32(pending_fn) if uname() == 'Windows' then if pending_fn ~= nil then pending_fn('FIXME: Windows', function() end) end return true else return false end end -- Calls pending() and returns `true` if the system is too slow to -- run fragile or expensive tests. Else returns `false`. local function skip_fragile(pending_fn, cond) if pending_fn == nil or type(pending_fn) ~= type(function()end) then error("invalid pending_fn") end if cond then pending_fn("skipped (test is fragile on this system)", function() end) return true elseif os.getenv("TEST_SKIP_FRAGILE") then pending_fn("skipped (TEST_SKIP_FRAGILE)", function() end) return true end return false end local function meth_pcall(...) local ret = {pcall(...)} if type(ret[2]) == 'string' then ret[2] = ret[2]:gsub('^[^:]+:%d+: ', '') end return ret end local funcs = create_callindex(nvim_call) local meths = create_callindex(nvim) local uimeths = create_callindex(ui) local bufmeths = create_callindex(buffer) local winmeths = create_callindex(window) local tabmeths = create_callindex(tabpage) local curbufmeths = create_callindex(curbuf) local curwinmeths = create_callindex(curwin) local curtabmeths = create_callindex(curtab) local function redir_exec(cmd) meths.set_var('__redir_exec_cmd', cmd) nvim_command([[ redir => g:__redir_exec_output silent! execute g:__redir_exec_cmd redir END ]]) local ret = meths.get_var('__redir_exec_output') meths.del_var('__redir_exec_output') meths.del_var('__redir_exec_cmd') return ret end local function get_pathsep() return iswin() and '\\' or '/' end local function pathroot() local pathsep = package.config:sub(1,1) return iswin() and (nvim_dir:sub(1,2)..pathsep) or '/' end -- Returns a valid, platform-independent $NVIM_LISTEN_ADDRESS. -- Useful for communicating with child instances. local function new_pipename() -- HACK: Start a server temporarily, get the name, then stop it. local pipename = nvim_eval('serverstart()') funcs.serverstop(pipename) return pipename end local function missing_provider(provider) if provider == 'ruby' or provider == 'node' then local prog = funcs['provider#' .. provider .. '#Detect']() return prog == '' and (provider .. ' not detected') or false elseif provider == 'python' or provider == 'python3' then local py_major_version = (provider == 'python3' and 3 or 2) local errors = funcs['provider#pythonx#Detect'](py_major_version)[2] return errors ~= '' and errors or false else assert(false, 'Unknown provider: ' .. provider) end end local function alter_slashes(obj) if not iswin() then return obj end if type(obj) == 'string' then local ret = obj:gsub('/', '\\') return ret elseif type(obj) == 'table' then local ret = {} for k, v in pairs(obj) do ret[k] = alter_slashes(v) end return ret else assert(false, 'Could only alter slashes for tables of strings and strings') end end local function compute_load_factor() local timeout = 200 local times = {} clear() for _ = 1, 5 do source([[ let g:val = 0 call timer_start(200, {-> nvim_set_var('val', 1)}) let start = reltime() while 1 sleep 10m if g:val == 1 let g:waited_in_ms = float2nr(reltimefloat(reltime(start)) * 1000) break endif endwhile ]]) table.insert(times, nvim_eval('g:waited_in_ms')) end session:close() session = nil local longest = math.max(unpack(times)) local factor = (longest + 50.0) / timeout return factor end -- Compute load factor only once. local load_factor = compute_load_factor() local function load_adjust(num) return math.ceil(num * load_factor) end local module = { NIL = mpack.NIL, alter_slashes = alter_slashes, buffer = buffer, bufmeths = bufmeths, call = nvim_call, create_callindex = create_callindex, clear = clear, command = nvim_command, connect = connect, curbuf = curbuf, curbuf_contents = curbuf_contents, curbufmeths = curbufmeths, curtab = curtab, curtabmeths = curtabmeths, curwin = curwin, curwinmeths = curwinmeths, dedent = dedent, eq = eq, eval = nvim_eval, exc_exec = exc_exec, expect = expect, expect_any = expect_any, expect_err = expect_err, expect_msg_seq = expect_msg_seq, expect_twostreams = expect_twostreams, feed = feed, feed_command = feed_command, filter = filter, funcs = funcs, get_pathsep = get_pathsep, get_session = get_session, insert = insert, iswin = iswin, map = map, matches = matches, merge_args = merge_args, meth_pcall = meth_pcall, meths = meths, missing_provider = missing_provider, mkdir = lfs.mkdir, load_adjust = load_adjust, near = near, neq = neq, new_pipename = new_pipename, next_msg = next_msg, nvim = nvim, nvim_argv = nvim_argv, nvim_async = nvim_async, nvim_dir = nvim_dir, nvim_prog = nvim_prog, nvim_set = nvim_set, ok = ok, os_name = os_name, pathroot = pathroot, pending_win32 = pending_win32, prepend_argv = prepend_argv, rawfeed = rawfeed, read_file = read_file, redir_exec = redir_exec, request = request, retry = retry, rmdir = rmdir, run = run, run_session = run_session, set_session = set_session, set_shell_powershell = set_shell_powershell, skip_fragile = skip_fragile, sleep = sleep, source = source, spawn = spawn, stop = stop, table_flatten = table_flatten, tabmeths = tabmeths, tabpage = tabpage, tmpname = tmpname, uimeths = uimeths, wait = wait, window = window, winmeths = winmeths, write_file = write_file, } return function(after_each) if after_each then after_each(function() for _, fname in ipairs(sourced_fnames) do os.remove(fname) end check_logs() check_cores('build/bin/nvim') if session then local msg = session:next_message(0) if msg then if msg[1] == "notification" and msg[2] == "nvim_error_event" then error(msg[3][2]) end end end end) end return module end