Common library code for other vc*.nvim projects.
at main 261 lines 6.8 kB view raw
1local M = {} 2 3---@return boolean 4local function is_headless() 5 return #vim.api.nvim_list_uis() == 0 6end 7 8local function color(description) 9 local words = vim.split(description, " ", { plain = true }) 10 local colors = { 11 reset = -1, 12 black = 0, 13 red = 1, 14 green = 2, 15 yellow = 3, 16 blue = 4, 17 magenta = 5, 18 cyan = 6, 19 white = 7, 20 } 21 local base = 30 22 local color = nil 23 for _, word in ipairs(words) do 24 if word == "bright" then 25 base = 90 26 else 27 local c = colors[word] 28 if not c then 29 error("Unknown color: " .. word) 30 end 31 color = c 32 end 33 end 34 if not color then 35 error("No color specified in description: " .. description) 36 end 37 if color ~= -1 then 38 return string.format("\27[%dm", base + color) 39 end 40 return "\27[0m" 41end 42 43FAIL = color "bright red" 44PASS = color "bright green" 45NOTE = color "bright black" 46HEADER = color "bright blue" 47RESET = color "reset" 48 49---@param text string 50---@param color string 51---@return string 52local function colorize(text, color) 53 if not is_headless() then 54 -- Interactive session: do not use colors. 55 return text 56 end 57 return color .. text .. RESET 58end 59 60--- Output text using the appropriate method for current mode. 61---@param text string|string[] 62local function output(text) 63 if type(text) == "table" then 64 text = table.concat(text, " ") 65 end 66 if is_headless() then 67 io.stdout:write(text .. "\n") 68 io.stdout:flush() 69 else 70 print(text) 71 end 72end 73 74--- Helper to parse multiline strings into lines, stripping common indentation. 75---@param s string 76---@return string[] 77function M.dedent_into_lines(s) 78 local l = 1 79 while s:sub(l, l) == "\n" do 80 l = l + 1 81 end 82 local r = #s 83 while true do 84 local c = s:sub(r, r) 85 if c ~= "\n" and c ~= " " then 86 break 87 end 88 r = r - 1 89 end 90 local stripped = s:sub(l, r) 91 local lines = vim.split(stripped, "\n", { plain = true }) 92 local min_indent = math.huge 93 for _, line in ipairs(lines) do 94 local indent = #line - #line:gsub("^%s*", "") 95 if #line > 0 and indent < min_indent then 96 min_indent = indent 97 end 98 end 99 if min_indent == math.huge then 100 min_indent = 0 101 end 102 for i, line in ipairs(lines) do 103 lines[i] = line:sub(min_indent + 1) 104 end 105 return lines 106end 107 108--- Helper to parse multiline strings into lines, stripping common indentation. 109---@param s string 110---@return string 111function M.dedent(s) 112 local lines = M.dedent_into_lines(s) 113 return table.concat(lines, "\n") .. "\n" 114end 115 116local function _run_test_suite( 117 module_name, 118 suite_name, 119 test_suite, 120 should_run_test 121) 122 local suite_start_time = vim.uv.hrtime() 123 local suite_failed = 0 124 local suite_total = 0 125 local suite_skipped = 0 126 local test_cases = test_suite.test_cases 127 local test_function = test_suite.test 128 for case_name, case in pairs(test_cases) do 129 local full_test_name = 130 string.format("%s::%s__%s", module_name, suite_name, case_name) 131 if should_run_test(full_test_name) then 132 local status, err = pcall(function() 133 test_function(case) 134 end) 135 if not status then 136 suite_failed = suite_failed + 1 137 output(colorize("✗ FAIL", FAIL) .. " " .. full_test_name) 138 if err then 139 -- Massage errors into a more readable format. 140 err = err:gsub(":(%s)", ":\n", 1) 141 err = " " .. err:gsub("\n", "\n ") 142 output(err) 143 end 144 end 145 suite_total = suite_total + 1 146 else 147 suite_skipped = suite_skipped + 1 148 end 149 end 150 151 local duration_ms = (vim.uv.hrtime() - suite_start_time) / 1e6 152 153 local symbol 154 local outcome 155 local name = suite_name 156 local timing = string.format("(%.1fms)", duration_ms) 157 local blocks 158 if suite_total == 0 then 159 symbol = colorize("-", NOTE) 160 name = colorize(suite_name, NOTE) 161 blocks = { symbol, name } 162 else 163 if suite_failed == 0 then 164 symbol = colorize("", PASS) 165 outcome = string.format("(all %d passed)", suite_total) 166 blocks = { symbol, suite_name, outcome, timing } 167 else 168 symbol = colorize("", FAIL) 169 outcome = string.format("(%d/%d failed)", suite_failed, suite_total) 170 blocks = { symbol, suite_name, outcome, timing } 171 end 172 end 173 if suite_skipped > 0 and suite_total == 0 then 174 blocks[#blocks + 1] = 175 colorize(string.format("(all %d skipped)", suite_skipped), NOTE) 176 else 177 blocks[#blocks + 1] = 178 colorize(string.format("(%d skipped)", suite_skipped), NOTE) 179 end 180 output(blocks) 181 return suite_failed, suite_total, suite_skipped 182end 183 184function M.run_tests(test_modules) 185 local options = { filter = vim.env.TEST_FILTER } 186 local function should_run_test(name) 187 if not options.filter then 188 return true 189 end 190 191 -- Very magic for convenience. 192 local pattern = "\\v" .. options.filter 193 local ok, regex = pcall(vim.regex, pattern) 194 if not ok then 195 error("Invalid filter regex: " .. options.filter) 196 end 197 return regex:match_str(name) ~= nil 198 end 199 200 local start_time = vim.uv.hrtime() 201 local failed = 0 202 local total = 0 203 local skipped = 0 204 for _, test_module_name in ipairs(test_modules) do 205 local test_module = require(test_module_name) 206 -- Slightly less verbose name for the test module, removing first component. 207 local file_name = test_module_name:match "^[^.]+%.(.+)$" or test_module_name 208 output( 209 colorize(string.format("=== Running tests in %s ===", file_name), HEADER) 210 ) 211 for suite_name, test_suite in pairs(test_module) do 212 local suite_failed, suite_total, suite_skipped = 213 _run_test_suite(file_name, suite_name, test_suite, should_run_test) 214 failed = failed + suite_failed 215 total = total + suite_total 216 skipped = skipped + suite_skipped 217 end 218 end 219 220 local total_duration_ms = (vim.uv.hrtime() - start_time) / 1e6 221 output "--------------------------------" 222 local timing = string.format("(%.1fms)", total_duration_ms) 223 local msg 224 if failed == 0 then 225 msg = colorize("All tests passed", PASS) 226 else 227 msg = colorize(string.format("%d/%d tests failed", failed, total), FAIL) 228 end 229 if skipped > 0 then 230 msg = msg .. colorize(string.format(" (%d skipped)", skipped), NOTE) 231 end 232 233 output { msg, timing } 234 if failed > 0 then 235 vim.cmd "cq" 236 end 237end 238 239function M.assert_list_eq(actual, expected, msg_prefix) 240 msg_prefix = msg_prefix or "" 241 assert( 242 #actual == #expected, 243 string.format( 244 msg_prefix .. "Lists have different lengths: %d vs %d", 245 #actual, 246 #expected 247 ) 248 ) 249 local diff = "" 250 for i = 1, #expected do 251 if actual[i] ~= expected[i] then 252 diff = diff 253 .. string.format("\n[%d]: %s ~= %s", i, actual[i], expected[i]) 254 end 255 end 256 if diff ~= "" then 257 error(msg_prefix .. "Lists differ:" .. diff) 258 end 259end 260 261return M