Common library code for other vc*.nvim projects.
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