local util = require("test-samurai.util") local M = {} local default_filetypes = { javascript = true, javascriptreact = true, typescript = true, typescriptreact = true, } local default_patterns = { ".test.", ".spec." } local function is_js_test_file(bufnr, filetypes, patterns) local ft = vim.bo[bufnr].filetype if not filetypes[ft] then return false end local path = util.get_buf_path(bufnr) if not path or path == "" then return false end for _, pat in ipairs(patterns) do if path:find(pat, 1, true) then return true end end return false end local function append_args(cmd, args) if not args or #args == 0 then return end for _, arg in ipairs(args) do table.insert(cmd, arg) end end local function escape_regex(s) s = s or "" return (s:gsub("([\\.^$|()%%[%]{}*+?%-])", "\\\\%1")) end local function build_pattern(names) local parts = {} for _, name in ipairs(names or {}) do if name and name ~= "" then table.insert(parts, escape_regex(name)) end end if #parts == 0 then return nil end return table.concat(parts, "|") end local function find_test_file_arg(cmd) if not cmd then return nil end for i = #cmd, 1, -1 do local arg = cmd[i] if type(arg) == "string" and arg:sub(1, 1) ~= "-" then if arg:match("%.test%.[jt]sx?$") or arg:match("%.spec%.[jt]sx?$") then return arg end end end return nil end local function find_block_end(lines, start_idx) local depth = 0 local started = false for i = start_idx, #lines do local line = lines[i] for j = 1, #line do local ch = line:sub(j, j) if ch == "{" then depth = depth + 1 started = true elseif ch == "}" then if started then depth = depth - 1 if depth == 0 then return i - 1 end end end end end return #lines - 1 end local jest_config_files = { "jest.config.js", "jest.config.ts" } local function find_jest_root(file) if not file or file == "" then return util.find_root(file, { "jest.config.js", "jest.config.ts", "vitest.config.ts", "vitest.config.js", "package.json", "node_modules", }) end local dir = vim.fs.dirname(file) local found = vim.fs.find(jest_config_files, { path = dir, upward = true }) if found and #found > 0 then local cfg = found[1] local sep = package.config:sub(1, 1) local marker = sep .. "test" .. sep .. ".bin" .. sep local idx = cfg:find(marker, 1, true) if idx then local root = cfg:sub(1, idx - 1) if root == "" then root = sep end return root end return vim.fs.dirname(cfg) end return util.find_root(file, { "jest.config.js", "jest.config.ts", "vitest.config.ts", "vitest.config.js", "package.json", "node_modules", }) end local function match_test_call(line) local call, name = line:match("^%s*(it)%s*%(%s*['\"`]([^'\"`]+)['\"`]") if call then return call, name end call, name = line:match("^%s*(test)%s*%(%s*['\"`]([^'\"`]+)['\"`]") if call then return call, name end call, name = line:match("^%s*(describe)%s*%(%s*['\"`]([^'\"`]+)['\"`]") if call and name then return call, name end return nil, nil end local function collect_js_structs(lines) local describes = {} local tests = {} for i, line in ipairs(lines) do local dcall, dname = line:match("^%s*(describe)%s*%(%s*['\"`]([^'\"`]+)['\"`]") if dcall and dname then local start0 = i - 1 local end0 = find_block_end(lines, i) table.insert(describes, { kind = dcall, name = dname, start = start0, ["end"] = end0, }) else local tcall, tname = line:match("^%s*(it)%s*%(%s*['\"`]([^'\"`]+)['\"`]") if not tcall then tcall, tname = line:match("^%s*(test)%s*%(%s*['\"`]([^'\"`]+)['\"`]") end if tcall and tname then local start0 = i - 1 local end0 = find_block_end(lines, i) table.insert(tests, { kind = tcall, name = tname, start = start0, ["end"] = end0, }) end end end return describes, tests end local build_full_name local shorten_js_name local normalize_js_name local function add_location(target, name, file, line) if not name or name == "" or not file or file == "" or not line then return end if not target[name] then target[name] = {} end table.insert(target[name], { filename = file, lnum = line, col = 1, text = name, }) end local function collect_js_locations(lines, file, target) local describes, tests = collect_js_structs(lines) for _, t in ipairs(tests) do local full = build_full_name(lines, t.start + 1, t.name) add_location(target, full, file, t.start + 1) add_location(target, t.name, file, t.start + 1) local short = shorten_js_name and shorten_js_name(full) or nil if short and short ~= full then add_location(target, short, file, t.start + 1) end end for _, d in ipairs(describes) do local full = build_full_name(lines, d.start + 1, d.name) add_location(target, full, file, d.start + 1) add_location(target, d.name, file, d.start + 1) local short = shorten_js_name and shorten_js_name(full) or nil if short and short ~= full then add_location(target, short, file, d.start + 1) end end end local function collect_js_test_files(root, patterns) if not root or root == "" then root = vim.loop.cwd() end local files = {} local seen = {} for _, pat in ipairs(patterns or {}) do local glob = "**/*" .. pat .. "*" local hits = vim.fn.globpath(root, glob, false, true) if type(hits) == "table" then for _, file in ipairs(hits) do if not seen[file] then seen[file] = true table.insert(files, file) end end end end return files end build_full_name = function(lines, idx, leaf_name) local parts = { leaf_name } for i = idx - 1, 1, -1 do local line = lines[i] local call, name = line:match("^%s*(describe)%s*%(%s*['\"`]([^'\"`]+)['\"`]") if call and name then table.insert(parts, 1, name) end end return table.concat(parts, " ") end local function find_nearest_test(bufnr, row) local lines = util.get_buf_lines(bufnr) local describes, tests = collect_js_structs(lines) for _, t in ipairs(tests) do if row >= t.start and row <= t["end"] then local full = build_full_name(lines, t.start + 1, t.name) return { kind = t.kind, name = t.name, full_name = full, line = t.start, } end end local best_describe = nil for _, d in ipairs(describes) do if row >= d.start and row <= d["end"] then if not best_describe or d.start >= best_describe.start then best_describe = d end end end if best_describe then local full = build_full_name(lines, best_describe.start + 1, best_describe.name) return { kind = "describe", name = best_describe.name, full_name = full, line = best_describe.start, } end local start = row + 1 if start > #lines then start = #lines elseif start < 1 then start = 1 end for i = start, 1, -1 do local line = lines[i] local call, name = match_test_call(line) if call and name then local full = build_full_name(lines, i, name) return { kind = call, name = name, full_name = full, line = i - 1, } end end return nil end function M.new(opts) local cfg = opts or {} local runner = {} runner.name = cfg.name or "js" runner.framework = cfg.framework or "jest" runner.command = cfg.command or { "npx", runner.framework } runner.all_glob = cfg.all_glob runner.json_args = cfg.json_args or {} runner.failed_only_flag = cfg.failed_only_flag runner.filetypes = {} if cfg.filetypes then for _, ft in ipairs(cfg.filetypes) do runner.filetypes[ft] = true end else runner.filetypes = vim.deepcopy(default_filetypes) end runner.patterns = cfg.patterns or default_patterns function runner.is_test_file(bufnr) return is_js_test_file(bufnr, runner.filetypes, runner.patterns) end function runner.find_nearest(bufnr, row, _col) if not runner.is_test_file(bufnr) then return nil, "not a JS/TS test file" end local hit = find_nearest_test(bufnr, row) if not hit then return nil, "no test call found" end local path = util.get_buf_path(bufnr) local root if runner.framework == "jest" then root = find_jest_root(path) else root = util.find_root(path, { "jest.config.js", "jest.config.ts", "vitest.config.ts", "vitest.config.js", "package.json", "node_modules", }) end return { file = path, cwd = root, framework = runner.framework, test_name = hit.name, full_name = hit.full_name, kind = hit.kind, } end local function build_jest(spec) local cmd = vim.deepcopy(runner.command) append_args(cmd, runner.json_args) table.insert(cmd, spec.file) table.insert(cmd, "-t") table.insert(cmd, spec.test_name) return cmd end local function build_mocha(spec) local cmd = vim.deepcopy(runner.command) append_args(cmd, runner.json_args) local target = spec.full_name or spec.test_name table.insert(cmd, "--fgrep") table.insert(cmd, target) table.insert(cmd, spec.file) return cmd end local function build_vitest(spec) local cmd = vim.deepcopy(runner.command) append_args(cmd, runner.json_args) table.insert(cmd, spec.file) table.insert(cmd, "-t") table.insert(cmd, spec.test_name) return cmd end function runner.build_command(spec) local fw = runner.framework local cmd if fw == "jest" then cmd = build_jest(spec) elseif fw == "mocha" then cmd = build_mocha(spec) elseif fw == "vitest" then cmd = build_vitest(spec) else cmd = vim.deepcopy(runner.command) table.insert(cmd, spec.file) end return { cmd = cmd, cwd = spec.cwd, } end function runner.build_file_command(bufnr) local path = util.get_buf_path(bufnr) if not path or path == "" then return nil end local root if runner.framework == "jest" then root = find_jest_root(path) else root = util.find_root(path, { "jest.config.js", "jest.config.ts", "vitest.config.ts", "vitest.config.js", "package.json", "node_modules", }) end local cmd = vim.deepcopy(runner.command) append_args(cmd, runner.json_args) table.insert(cmd, path) return { cmd = cmd, cwd = root, } end function runner.build_all_command(bufnr) local path = util.get_buf_path(bufnr) local root if path and path ~= "" then if runner.framework == "jest" then root = find_jest_root(path) else root = util.find_root(path, { "jest.config.js", "jest.config.ts", "vitest.config.ts", "vitest.config.js", "package.json", "node_modules", }) end end if not root or root == "" then root = vim.loop.cwd() end local cmd = vim.deepcopy(runner.command) append_args(cmd, runner.json_args) if runner.framework == "mocha" and runner.all_glob then table.insert(cmd, runner.all_glob) end return { cmd = cmd, cwd = root, } end local function parse_jest_results(data) if type(data) ~= "table" then return nil end local passes = {} local failures = {} local skips = {} local display = { passes = {}, failures = {}, skips = {} } for _, result in ipairs(data.testResults or {}) do for _, assertion in ipairs(result.assertionResults or {}) do local full = assertion.fullName or assertion.title local short = assertion.title or assertion.fullName if assertion.status == "passed" and full then table.insert(passes, full) if short then table.insert(display.passes, short) end elseif assertion.status == "failed" and full then table.insert(failures, full) if short then table.insert(display.failures, short) end elseif (assertion.status == "pending" or assertion.status == "skipped" or assertion.status == "todo") and full then table.insert(skips, full) if short then table.insert(display.skips, short) end end end end return { passes = passes, failures = failures, skips = skips, display = display } end local function split_output_lines(text) if not text or text == "" then return {} end local lines = vim.split(text, "\n", { plain = true }) if #lines > 0 and lines[#lines] == "" then table.remove(lines, #lines) end return lines end local function append_output(out, name, text) if not name or name == "" or not text or text == "" then return end if not out[name] then out[name] = {} end for _, line in ipairs(split_output_lines(text)) do table.insert(out[name], line) end end local function decode_json_from_output(output) local ok, data = pcall(vim.json.decode, output) if ok and type(data) == "table" then return data end if not output or output == "" then return nil end local start_idx = output:find("{", 1, true) if not start_idx then return nil end local end_idx = nil for i = #output, start_idx, -1 do if output:sub(i, i) == "}" then end_idx = i break end end if not end_idx then return nil end local candidate = output:sub(start_idx, end_idx) local ok2, data2 = pcall(vim.json.decode, candidate) if ok2 and type(data2) == "table" then return data2 end return nil end local function collect_jest_failure_output(data) local out = {} for _, result in ipairs(data.testResults or {}) do for _, assertion in ipairs(result.assertionResults or {}) do if assertion.status == "failed" then local name = assertion.fullName or assertion.title for _, msg in ipairs(assertion.failureMessages or {}) do append_output(out, name, msg) end end end end return out end local function collect_mocha_failure_output(output) local out = {} local ok, data = pcall(vim.json.decode, output) if ok and type(data) == "table" then for _, failure in ipairs(data.failures or {}) do local name = failure.fullTitle or failure.title local err = failure.err or failure local message = nil if type(err) == "table" then message = err.stack or err.message elseif type(err) == "string" then message = err end append_output(out, name, message) end return out end for line in (output or ""):gmatch("[^\n]+") do local ok_line, entry = pcall(vim.json.decode, line) if ok_line and type(entry) == "table" then local event = entry.event or entry[1] or entry["1"] local payload = entry if not entry.event then payload = entry[2] or entry["2"] or {} end if event == "fail" then local name = payload.fullTitle or payload.title local err = payload.err or payload local message = nil if type(err) == "table" then message = err.stack or err.message elseif type(err) == "string" then message = err end append_output(out, name, message) end end end return out end local function parse_jest_like(output) local ok, data = pcall(vim.json.decode, output) if ok and type(data) == "table" then return parse_jest_results(data) end if not output or output == "" then return nil end local start_idx = output:find("{", 1, true) if not start_idx then return nil end local end_idx = nil for i = #output, start_idx, -1 do if output:sub(i, i) == "}" then end_idx = i break end end if not end_idx then return nil end local candidate = output:sub(start_idx, end_idx) local ok2, data2 = pcall(vim.json.decode, candidate) if not ok2 or type(data2) ~= "table" then return nil end return parse_jest_results(data2) end local jest_symbols = { pass = string.char(0xE2, 0x9C, 0x93), fail = string.char(0xE2, 0x9C, 0x95), skip = string.char(0xE2, 0x97, 0x8B), } normalize_js_name = function(name) if not name or name == "" then return nil end local out = name out = out:gsub("%s*[%>›»]%s*", " ") out = out:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") return out end shorten_js_name = function(name) if not name or name == "" then return nil end local last = name:match(".*>%s*([^>]+)$") if last then return last:gsub("^%s+", ""):gsub("%s+$", "") end last = name:match(".*›%s*([^›]+)$") if last then return last:gsub("^%s+", ""):gsub("%s+$", "") end last = name:match(".*»%s*([^»]+)$") if last then return last:gsub("^%s+", ""):gsub("%s+$", "") end return name end local function trim_jest_verbose_name(name) if not name then return nil end name = name:gsub("%s+%(%d+%.?%d*%s*ms%)%s*$", "") name = name:gsub("%s+%(%d+%.?%d*%s*sec%)%s*$", "") name = name:gsub("%s+%b()%s*$", "") name = name:gsub("%s+$", "") return name end local function parse_jest_verbose_line(line) if not line or line == "" then return nil, nil end local name = line:match("^%s*" .. jest_symbols.pass .. "%s+(.+)$") if name then return "pass", trim_jest_verbose_name(name) end name = line:match("^%s*" .. jest_symbols.fail .. "%s+(.+)$") if name then return "fail", trim_jest_verbose_name(name) end name = line:match("^%s*" .. jest_symbols.skip .. "%s+(.+)$") if name then return "skip", trim_jest_verbose_name(name) end return nil, nil end local function parse_mocha(output) local ok, data = pcall(vim.json.decode, output) if not ok or type(data) ~= "table" then local passes = {} local failures = {} local skips = {} local display = { passes = {}, failures = {}, skips = {} } for line in (output or ""):gmatch("[^\n]+") do local ok_line, entry = pcall(vim.json.decode, line) if ok_line and type(entry) == "table" then local event = entry.event or entry[1] or entry["1"] local payload = entry if not entry.event then payload = entry[2] or entry["2"] or {} end local full = payload.fullTitle or payload.title local short = payload.title or payload.fullTitle if event == "pass" and full then table.insert(passes, full) if short then table.insert(display.passes, short) end elseif event == "fail" and full then table.insert(failures, full) if short then table.insert(display.failures, short) end elseif event == "pending" and full then table.insert(skips, full) if short then table.insert(display.skips, short) end end end end if #passes == 0 and #failures == 0 and #skips == 0 then return nil end return { passes = passes, failures = failures, skips = skips, display = display } end local passes = {} local failures = {} local skips = {} local display = { passes = {}, failures = {}, skips = {} } if type(data.tests) == "table" then for _, test in ipairs(data.tests) do local full = test.fullTitle or test.title local short = test.title or test.fullTitle if test.state == "passed" and full then table.insert(passes, full) if short then table.insert(display.passes, short) end elseif test.state == "failed" and full then table.insert(failures, full) if short then table.insert(display.failures, short) end elseif test.state == "pending" and full then table.insert(skips, full) if short then table.insert(display.skips, short) end end end elseif type(data.passes) == "table" or type(data.failures) == "table" then for _, test in ipairs(data.passes or {}) do local full = test.fullTitle or test.title local short = test.title or test.fullTitle if full then table.insert(passes, full) if short then table.insert(display.passes, short) end end end for _, test in ipairs(data.failures or {}) do local full = test.fullTitle or test.title local short = test.title or test.fullTitle if full then table.insert(failures, full) if short then table.insert(display.failures, short) end end end for _, test in ipairs(data.pending or {}) do local full = test.fullTitle or test.title local short = test.title or test.fullTitle if full then table.insert(skips, full) if short then table.insert(display.skips, short) end end end end return { passes = passes, failures = failures, skips = skips, display = display } end local function parse_output(output) if runner.framework == "mocha" then return parse_mocha(output) end return parse_jest_like(output) end function runner.parse_results(output) return parse_output(output) end function runner.parse_test_output(output) if runner.framework == "mocha" then return collect_mocha_failure_output(output) end local data = decode_json_from_output(output) if not data then return {} end return collect_jest_failure_output(data) end function runner.output_parser() local state = { raw = {}, done = false, saw_stream = false } local failures = {} local skips = {} local jest_seen_pass = {} local jest_seen_fail = {} local jest_seen_skip = {} local jest_json_collecting = false local jest_json_buffer = {} local jest_streamed = false local function reset_list(dst, src) for i = #dst, 1, -1 do dst[i] = nil end for _, item in ipairs(src or {}) do if item and item ~= "" then table.insert(dst, item) end end end local function emit_jest_results(results, emit_output) if not results then return nil end local display = results.display or {} if emit_output == false then reset_list(failures, results.failures or {}) return { passes = {}, failures = {}, skips = {}, failures_all = vim.deepcopy(failures), } end local out = { passes = {}, failures = {}, skips = {}, failures_all = {}, display = { passes = {}, failures = {}, skips = {} }, } for i, title in ipairs(results.passes or {}) do if title and not jest_seen_pass[title] then jest_seen_pass[title] = true table.insert(out.passes, title) local show = display.passes and display.passes[i] or title table.insert(out.display.passes, show) end end for i, title in ipairs(results.failures or {}) do if title and not jest_seen_fail[title] then jest_seen_fail[title] = true table.insert(failures, title) table.insert(out.failures, title) local show = display.failures and display.failures[i] or title table.insert(out.display.failures, show) end end for i, title in ipairs(results.skips or {}) do if title and not jest_seen_skip[title] then jest_seen_skip[title] = true table.insert(out.skips, title) local show = display.skips and display.skips[i] or title table.insert(out.display.skips, show) end end out.failures_all = vim.deepcopy(failures) if #out.passes == 0 and #out.failures == 0 and #out.skips == 0 then return nil end return out end local function feed_jest_json(line) if not jest_json_collecting then if not line:match("^%s*{") then return nil end jest_json_collecting = true jest_json_buffer = { line } else table.insert(jest_json_buffer, line) end local candidate = table.concat(jest_json_buffer, "\n") local ok_buf, data_buf = pcall(vim.json.decode, candidate) if ok_buf and type(data_buf) == "table" then jest_json_collecting = false jest_json_buffer = {} return data_buf end return nil end local function handle_jest_json(data) local results = parse_jest_results(data) if not results then return nil end state.done = true state.saw_stream = true return emit_jest_results(results, not jest_streamed) end return { on_line = function(line, _state) if state.done then return nil end if runner.framework == "jest" then local kind, name = parse_jest_verbose_line(line) if kind and name then jest_streamed = true local results = { passes = {}, failures = {}, skips = {} } if kind == "pass" then results.passes = { name } elseif kind == "fail" then results.failures = { name } elseif kind == "skip" then results.skips = { name } end results.display = { passes = kind == "pass" and { name } or {}, failures = kind == "fail" and { name } or {}, skips = kind == "skip" and { name } or {}, } return emit_jest_results(results, true) end local data = feed_jest_json(line) if data then return handle_jest_json(data) end return nil end if runner.framework == "mocha" and runner.json_args then local uses_stream = false for i = 1, #runner.json_args - 1 do if runner.json_args[i] == "--reporter" and runner.json_args[i + 1] == "json-stream" then uses_stream = true break end end if uses_stream then local ok, data = pcall(vim.json.decode, line) if ok and type(data) == "table" then local event = data.event local payload = data if not event then event = data[1] or data["1"] payload = data[2] or data["2"] or {} end if event == "pass" and payload.fullTitle then state.saw_stream = true return { passes = { payload.fullTitle }, failures = {}, skips = {}, display = { passes = { payload.title or payload.fullTitle }, failures = {}, skips = {}, }, failures_all = vim.deepcopy(failures), } elseif event == "fail" and payload.fullTitle then state.saw_stream = true table.insert(failures, payload.fullTitle) return { passes = {}, failures = { payload.fullTitle }, skips = {}, display = { passes = {}, failures = { payload.title or payload.fullTitle }, skips = {}, }, failures_all = vim.deepcopy(failures), } elseif event == "pending" and payload.fullTitle then state.saw_stream = true table.insert(skips, payload.fullTitle) return { passes = {}, failures = {}, skips = { payload.fullTitle }, display = { passes = {}, failures = {}, skips = { payload.title or payload.fullTitle }, }, failures_all = vim.deepcopy(failures), } elseif event == "start" or event == "end" then state.saw_stream = true return { passes = {}, failures = {}, skips = {}, failures_all = vim.deepcopy(failures), } end end return nil end end if runner.framework == "vitest" and runner.json_args then local uses_tap = false for i = 1, #runner.json_args - 1 do if runner.json_args[i] == "--reporter" and (runner.json_args[i + 1] == "tap" or runner.json_args[i + 1] == "tap-flat") then uses_tap = true break end end if uses_tap then local ok_title = line:match("^ok%s+%d+%s+%-%s+(.+)") if ok_title then local skip_title = ok_title:match("^(.-)%s+#%s+SKIP.*$") if skip_title then skip_title = skip_title:gsub("%s+$", "") table.insert(skips, skip_title) state.saw_stream = true return { passes = {}, failures = {}, skips = { skip_title }, display = { passes = {}, failures = {}, skips = { shorten_js_name(skip_title) or skip_title }, }, failures_all = vim.deepcopy(failures), } end ok_title = ok_title:gsub("%s+#%s+time=.*$", "") state.saw_stream = true return { passes = { ok_title }, failures = {}, skips = {}, display = { passes = { shorten_js_name(ok_title) or ok_title }, failures = {}, skips = {}, }, failures_all = vim.deepcopy(failures), } end local fail_title = line:match("^not ok%s+%d+%s+%-%s+(.+)") if fail_title then fail_title = fail_title:gsub("%s+#%s+time=.*$", "") local display_name = shorten_js_name(fail_title) table.insert(failures, fail_title) state.saw_stream = true return { passes = {}, failures = { fail_title }, skips = {}, display = { passes = {}, failures = { display_name or fail_title }, skips = {}, }, failures_all = vim.deepcopy(failures), } end return nil end end table.insert(state.raw, line) local output = table.concat(state.raw, "\n") local results = parse_output(output) if results then state.done = true end return results end, on_complete = function(output, _state) if runner.framework == "jest" then if state.done then return nil end local results = parse_jest_like(output) if results then state.done = true state.saw_stream = true if _state and _state.scope_kind == "all" and jest_streamed then return emit_jest_results(results, false) end return emit_jest_results(results, not jest_streamed) end return nil end if state.done or state.saw_stream then return nil end local results = parse_output(output) if results then state.done = true end return results end, } end function runner.build_failed_command(last_command, failures, scope_kind) local pattern = build_pattern(failures) if not pattern then return nil end local cmd = vim.deepcopy(runner.command) append_args(cmd, runner.json_args) if runner.framework == "mocha" then if #failures == 1 then table.insert(cmd, "--fgrep") table.insert(cmd, failures[1]) else table.insert(cmd, "--grep") table.insert(cmd, pattern) end else table.insert(cmd, "-t") table.insert(cmd, pattern) end if scope_kind ~= "all" and last_command then local file = find_test_file_arg(last_command.cmd) if file then table.insert(cmd, file) end end return { cmd = cmd, cwd = last_command and last_command.cwd or nil, } end function runner.collect_failed_locations(failures, command, scope_kind) if type(failures) ~= "table" or #failures == 0 then return {} end local files = {} if scope_kind == "all" then files = collect_js_test_files(command and command.cwd or nil, runner.patterns) else local file = command and command.file or nil if not file and command and type(command.cmd) == "table" then file = find_test_file_arg(command.cmd) end if file then files = { file } end end if #files == 0 then return {} end local locations = {} for _, file in ipairs(files) do local ok, lines = pcall(vim.fn.readfile, file) if ok and type(lines) == "table" then collect_js_locations(lines, file, locations) end end local items = {} local seen = {} for _, name in ipairs(failures) do local keys = { name } if normalize_js_name then local normalized = normalize_js_name(name) if normalized and normalized ~= name then table.insert(keys, normalized) end end if shorten_js_name then local short = shorten_js_name(name) if short and short ~= name then table.insert(keys, short) end end for _, key_name in ipairs(keys) do for _, loc in ipairs(locations[key_name] or {}) do local key = string.format("%s:%d", loc.filename or "", loc.lnum or 0) if not seen[key] then seen[key] = true table.insert(items, loc) end end end end return items end return runner end return M