diff --git a/.emmyrc.json b/.emmyrc.json index 173fac7..ad70c06 100644 --- a/.emmyrc.json +++ b/.emmyrc.json @@ -2,7 +2,7 @@ "$schema": "https://raw.githubusercontent.com/EmmyLuaLs/emmylua-analyzer-rust/refs/heads/main/crates/emmylua_code_analysis/resources/schema.json", "runtime": { "version": "LuaJIT", - "requirePattern": ["lua/?.lua", "lua/?/init.lua", "test/?.lua"] + "requirePattern": ["lua/?.lua", "lua/?/init.lua"] }, "diagnostics": { "disable": ["unnecessary-if", "preferred-local-alias", "redefined-local"] @@ -10,7 +10,21 @@ "workspace": { "library": [ "/usr/share/nvim/runtime", - "~/.local/share/nvim/site/pack/core/opt" + "/usr/share/nvim/runtime/pack/dist/opt/nvim.undotree", + "~/.local/share/nvim/site/pack/core/opt/onedark.nvim", + "~/.local/share/nvim/site/pack/core/opt/fzf-lua", + "~/.local/share/nvim/site/pack/core/opt/nvim-lspconfig", + "~/.local/share/nvim/site/pack/core/opt/mason.nvim", + "~/.local/share/nvim/site/pack/core/opt/mason-auto-install.nvim", + "~/.local/share/nvim/site/pack/core/opt/nvim-dap", + "~/.local/share/nvim/site/pack/core/opt/Comment.nvim", + "~/.local/share/nvim/site/pack/core/opt/gitsigns.nvim", + "~/.local/share/nvim/site/pack/core/opt/grug-far.nvim", + "~/.local/share/nvim/site/pack/core/opt/nvim-tree.lua", + "~/.local/share/nvim/site/pack/core/opt/oil.nvim", + "~/.local/share/nvim/site/pack/core/opt/outline.nvim", + "~/.local/share/nvim/site/pack/core/opt/blink.cmp", + "./test" ] } } diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..355027d --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +.PHONY: all check lint test + +all: check + +check: lint test + +test: + @scripts/test + +lint: + @scripts/lint diff --git a/scripts/lint b/scripts/lint new file mode 100755 index 0000000..43c1a9e --- /dev/null +++ b/scripts/lint @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -uo pipefail + +cd "$(dirname "$0")/.." || exit 1 + +emmylua_check --output-format=json . 2> >(grep -v '^Check finished$' >&2) \ + | jq -r ' + .[] + | .file as $f + | .diagnostics[] + | "\($f):\(.range.start.line + 1):\(.range.start.character + 1)" + + ": \(["error","warning","info","hint"][.severity-1])" + + ": \(.message | rtrimstr(" ")) [\(.code)]" + ' \ + | sed "s|^$PWD/||" + +exit "${PIPESTATUS[0]}" diff --git a/scripts/test b/scripts/test new file mode 100755 index 0000000..1d75628 --- /dev/null +++ b/scripts/test @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -u + +usage() { + cat <? +---@return string dir +local function make_repo(files) + local dir = vim.fn.tempname() + vim.fn.mkdir(dir, "p") + git(dir, "init", "-q", "-b", "main") + git(dir, "config", "user.email", "t@t.com") + git(dir, "config", "user.name", "t") + if files and next(files) then + for path, content in pairs(files) do + t.write(dir, path, content) + end + git(dir, "add", ".") + git(dir, "commit", "-q", "-m", "init") + end + t.defer(function() + pcall(vim.cmd.cd, "/tmp") + pcall(function() + require("git.repo").stop_all() + end) + vim.wait(60) + for _, b in ipairs(vim.api.nvim_list_bufs()) do + local name = vim.api.nvim_buf_get_name(b) + if name:find(dir, 1, true) or name:match("^git[a-z]*://") then + pcall(vim.api.nvim_buf_delete, b, { force = true }) + end + end + vim.fn.delete(dir, "rf") + end) + return dir +end + +---Replicate the user's global cursor-restore autocmd. Scoped to a +---named augroup + cleanup so it doesn't leak between tests. local function install_cursor_restore_autocmd() + local group = + vim.api.nvim_create_augroup("test.cursor_restore", { clear = true }) vim.api.nvim_create_autocmd("BufReadPost", { + group = group, pattern = "*", command = 'silent! normal! g`"zv', }) + t.defer(function() + pcall(vim.api.nvim_del_augroup_by_name, "test.cursor_restore") + end) end ---@param sidebar_buf integer ---@param needle string ---@return integer? local function find_line(sidebar_buf, needle) - for i, l in - ipairs(vim.api.nvim_buf_get_lines(sidebar_buf, 0, -1, false)) - do + for i, l in ipairs(vim.api.nvim_buf_get_lines(sidebar_buf, 0, -1, false)) do if l:match(needle) then return i end @@ -51,8 +105,6 @@ local function find_diff_win(role) end end ----Set up a repo with one tracked-and-modified file, open the sidebar, and ----return the sidebar window plus the line of the file's entry. ---@param file_path string ---@param committed_content string ---@param worktree_content string @@ -63,8 +115,8 @@ local function setup_sidebar_with_unstaged_file( committed_content, worktree_content ) - local repo = h.make_repo({ [file_path] = committed_content }) - h.write(repo, file_path, worktree_content) + local repo = make_repo({ [file_path] = committed_content }) + t.write(repo, file_path, worktree_content) vim.cmd("cd " .. repo) require("git.status_view").open({ placement = "sidebar" }) @@ -96,43 +148,40 @@ end ---@param cond fun(): boolean ---@param msg string local function wait_for(cond, msg) - h.truthy(vim.wait(1000, cond), "timed out waiting for: " .. msg) + t.truthy(vim.wait(1000, cond), "timed out waiting for: " .. msg) end -h.test( - "stage with diff open: sidebar cursor stays put", - function() - install_cursor_restore_autocmd() - local sidebar_win, line = setup_sidebar_with_unstaged_file( - "zsh/rc", - "ZSH=true\n", - "ZSH=true\nmodified\n" - ) +t.test("stage with diff open: sidebar cursor stays put", function() + install_cursor_restore_autocmd() + local sidebar_win, line = setup_sidebar_with_unstaged_file( + "zsh/rc", + "ZSH=true\n", + "ZSH=true\nmodified\n" + ) - vim.api.nvim_set_current_win(sidebar_win) - vim.api.nvim_win_set_cursor(sidebar_win, { line, 0 }) + vim.api.nvim_set_current_win(sidebar_win) + vim.api.nvim_win_set_cursor(sidebar_win, { line, 0 }) - press("") - wait_for(function() - return find_diff_win("left") ~= nil - end, "diff windows to appear") + press("") + wait_for(function() + return find_diff_win("left") ~= nil + end, "diff windows to appear") - local r = assert(require("git.repo").find(vim.fn.getcwd())) - vim.api.nvim_set_current_win(sidebar_win) - press("s") - wait_for(function() - return #r.status:by_kind("staged") > 0 - end, "stage to propagate to repo state") + local r = assert(require("git.repo").find(vim.fn.getcwd())) + vim.api.nvim_set_current_win(sidebar_win) + press("s") + wait_for(function() + return #r.status:by_kind("staged") > 0 + end, "stage to propagate to repo state") - h.eq( - vim.api.nvim_win_get_cursor(sidebar_win), - { line, 0 }, - "sidebar cursor should remain at the entry's original line" - ) - end -) + t.eq( + vim.api.nvim_win_get_cursor(sidebar_win), + { line, 0 }, + "sidebar cursor should remain at the entry's original line" + ) +end) -h.test( +t.test( "stage with diff open: diff foldmethod is preserved on refresh", function() local sidebar_win, line = setup_sidebar_with_unstaged_file( @@ -149,7 +198,7 @@ h.test( return find_diff_win("left") ~= nil end, "diff windows to appear") local left_win = assert(find_diff_win("left")) - h.eq( + t.eq( vim.wo[left_win].foldmethod, "diff", "left diff foldmethod should be 'diff' after Tab" @@ -162,7 +211,7 @@ h.test( return #r.status:by_kind("staged") > 0 end, "stage to propagate to repo state") - h.eq( + t.eq( vim.wo[left_win].foldmethod, "diff", "left diff foldmethod should still be 'diff' after stage refresh" @@ -170,12 +219,9 @@ h.test( end ) -h.test("refresh on stage updates the index URI buffer's content", function() - local sidebar_win, line = setup_sidebar_with_unstaged_file( - "foo.txt", - "v1\n", - "v2\n" - ) +t.test("refresh on stage updates the index URI buffer's content", function() + local sidebar_win, line = + setup_sidebar_with_unstaged_file("foo.txt", "v1\n", "v2\n") vim.api.nvim_set_current_win(sidebar_win) vim.api.nvim_win_set_cursor(sidebar_win, { line, 0 }) @@ -186,7 +232,7 @@ h.test("refresh on stage updates the index URI buffer's content", function() local left_win = assert(find_diff_win("left")) local index_buf = vim.api.nvim_win_get_buf(left_win) - h.eq( + t.eq( vim.api.nvim_buf_get_lines(index_buf, 0, -1, false), { "v1" }, "index pane should initially show committed content" @@ -195,16 +241,13 @@ h.test("refresh on stage updates the index URI buffer's content", function() vim.api.nvim_set_current_win(sidebar_win) press("s") wait_for(function() - local first = - vim.api.nvim_buf_get_lines(index_buf, 0, -1, false)[1] + local first = vim.api.nvim_buf_get_lines(index_buf, 0, -1, false)[1] return first == "v2" end, "index pane to refresh to staged content") - h.eq( + t.eq( vim.api.nvim_buf_get_lines(index_buf, 0, -1, false), { "v2" }, "index pane should reflect staged content after refresh" ) end) - -h.report() diff --git a/test/git/util_test.lua b/test/git/util_test.lua index a16b9c6..5c3d08c 100644 --- a/test/git/util_test.lua +++ b/test/git/util_test.lua @@ -1,47 +1,49 @@ -local h = require("helpers") +local t = require("test") local util = require("git.util") -h.test("set_buf_lines preserves modifiable=false", function() +local function fresh_buf() local buf = vim.api.nvim_create_buf(false, true) + t.defer(function() + pcall(vim.api.nvim_buf_delete, buf, { force = true }) + end) + return buf +end + +t.test("set_buf_lines preserves modifiable=false", function() + local buf = fresh_buf() vim.bo[buf].modifiable = false util.set_buf_lines(buf, 0, -1, { "a", "b", "c" }) - h.eq( + t.eq( vim.api.nvim_buf_get_lines(buf, 0, -1, false), { "a", "b", "c" }, "lines should be replaced" ) - h.falsy(vim.bo[buf].modifiable, "modifiable should stay false") - h.falsy(vim.bo[buf].modified, "modified should be cleared") - vim.api.nvim_buf_delete(buf, { force = true }) + t.falsy(vim.bo[buf].modifiable, "modifiable should stay false") + t.falsy(vim.bo[buf].modified, "modified should be cleared") end) -h.test("set_buf_lines preserves modifiable=true", function() - local buf = vim.api.nvim_create_buf(false, true) +t.test("set_buf_lines preserves modifiable=true", function() + local buf = fresh_buf() vim.bo[buf].modifiable = true util.set_buf_lines(buf, 0, -1, { "a", "b" }) - h.truthy(vim.bo[buf].modifiable, "modifiable should stay true") - h.falsy(vim.bo[buf].modified, "modified should be cleared") - vim.api.nvim_buf_delete(buf, { force = true }) + t.truthy(vim.bo[buf].modifiable, "modifiable should stay true") + t.falsy(vim.bo[buf].modified, "modified should be cleared") end) -h.test("set_buf_lines partial range update", function() - local buf = vim.api.nvim_create_buf(false, true) +t.test("set_buf_lines partial range update", function() + local buf = fresh_buf() vim.api.nvim_buf_set_lines(buf, 0, -1, false, { "a", "b", "c", "d" }) util.set_buf_lines(buf, 1, 3, { "X", "Y", "Z" }) - h.eq( + t.eq( vim.api.nvim_buf_get_lines(buf, 0, -1, false), { "a", "X", "Y", "Z", "d" }, "lines [1, 3) should be replaced" ) - vim.api.nvim_buf_delete(buf, { force = true }) end) -h.test("set_buf_lines errors on out-of-bounds (strict_indexing)", function() - local buf = vim.api.nvim_create_buf(false, true) +t.test("set_buf_lines errors on out-of-bounds (strict_indexing)", function() + local buf = fresh_buf() vim.api.nvim_buf_set_lines(buf, 0, -1, false, { "a", "b" }) local ok = pcall(util.set_buf_lines, buf, 100, 200, { "x" }) - h.falsy(ok, "out-of-bounds index should error") - vim.api.nvim_buf_delete(buf, { force = true }) + t.falsy(ok, "out-of-bounds index should error") end) - -h.report() diff --git a/test/helpers.lua b/test/init.lua similarity index 52% rename from test/helpers.lua rename to test/init.lua index fa15c01..2708bc2 100644 --- a/test/helpers.lua +++ b/test/init.lua @@ -1,7 +1,8 @@ local M = {} local stats = { passed = 0, failed = 0, errors = {} } -local pending_cleanup = {} +local defers = {} +local label = "?" local started = false local color_on = vim.env.TEST_COLOR == "1" @@ -12,11 +13,9 @@ local function color(code, str) end return str end - local function red(s) return color("31", s) end - local function green(s) return color("32", s) end @@ -26,51 +25,24 @@ local function ensure_started() return end started = true - io.stdout:write( - string.format("-> %s ", vim.env.TEST_FILE_LABEL or "?") - ) + io.stdout:write(string.format("-> %s ", label)) io.stdout:flush() end ----Tear down repos created during the current test. Stops fs watchers first, ----drains any scheduled callbacks, wipes buffers tied to the repo, then nukes ----the directory. ----@param dir string -function M.cleanup(dir) - pcall(vim.cmd.cd, "/tmp") - pcall(function() - require("git.repo").stop_all() - end) - vim.wait(60) - for _, b in ipairs(vim.api.nvim_list_bufs()) do - local name = vim.api.nvim_buf_get_name(b) - if name:find(dir, 1, true) or name:match("^git[a-z]*://") then - pcall(vim.api.nvim_buf_delete, b, { force = true }) - end - end - vim.fn.delete(dir, "rf") -end - ----@param name string ----@param fn fun() -function M.test(name, fn) - ensure_started() - local ok, err = pcall(fn) - while #pending_cleanup > 0 do - local dir = table.remove(pending_cleanup) - pcall(M.cleanup, dir) - end - if ok then - stats.passed = stats.passed + 1 - io.stdout:write(".") - else - stats.failed = stats.failed + 1 - table.insert(stats.errors, { name = name, err = err }) - io.stdout:write(red("F")) - end - io.stdout:flush() +---Begin a new test file. Resets per-file stats and the cleanup queue +---and stages the per-file header for the next `M.test` call. +---@param path string +function M.start_file(path) + label = path + started = false + stats = { passed = 0, failed = 0, errors = {} } + defers = {} end +---Print the per-file summary and return the counts so the runner can +---accumulate totals across files. +---@return integer passed +---@return integer failed function M.report() ensure_started() if stats.failed > 0 then @@ -88,12 +60,37 @@ function M.report() else io.stdout:write(" " .. green("OK") .. "\n") end - io.stdout:write( - string.format("RESULTS %d %d\n", stats.passed, stats.failed) - ) - if stats.failed > 0 then - vim.cmd("cquit 1") + return stats.passed, stats.failed +end + +---Queue a function to run when the current `M.test` completes (whether +---it passed or failed). Deferred calls run in LIFO order. +---@param fn fun() +function M.defer(fn) + table.insert(defers, fn) +end + +---@param name string +---@param fn fun() +function M.test(name, fn) + ensure_started() + local saved_cwd = vim.fn.getcwd() + local ok, err = pcall(fn) + while #defers > 0 do + pcall(table.remove(defers)) end + if vim.fn.getcwd() ~= saved_cwd then + pcall(vim.cmd.cd, saved_cwd) + end + if ok then + stats.passed = stats.passed + 1 + io.stdout:write(".") + else + stats.failed = stats.failed + 1 + table.insert(stats.errors, { name = name, err = err }) + io.stdout:write(red("F")) + end + io.stdout:flush() end local function fmt_value(v) @@ -136,56 +133,15 @@ function M.falsy(val, msg) end end ----@param dir string ----@param ... string -local function git(dir, ...) - local r = vim.system({ "git", ... }, { cwd = dir, text = true }):wait() - if r.code ~= 0 then - error( - string.format( - "git %s failed: %s", - table.concat({ ... }, " "), - vim.trim(r.stderr or "") - ), - 2 - ) - end - return r -end - ---@param dir string ---@param path string ---@param content string function M.write(dir, path, content) local full = vim.fs.joinpath(dir, path) vim.fn.mkdir(vim.fs.dirname(full), "p") - local f = assert(io.open(full, "w")) + local f = assert(io.open(full --[[@as string]], "w")) f:write(content) f:close() end ----Create a temporary git repo and seed it with files committed on `main`. ----The directory is auto-cleaned at the end of the current `M.test(...)`. ----@param files table? path -> content ----@return string dir ----@return fun(...): vim.SystemCompleted runner -function M.make_repo(files) - local dir = vim.fn.tempname() - vim.fn.mkdir(dir, "p") - git(dir, "init", "-q", "-b", "main") - git(dir, "config", "user.email", "t@t.com") - git(dir, "config", "user.name", "t") - if files and next(files) then - for path, content in pairs(files) do - M.write(dir, path, content) - end - git(dir, "add", ".") - git(dir, "commit", "-q", "-m", "init") - end - table.insert(pending_cleanup, dir) - return dir, function(...) - return git(dir, ...) - end -end - return M diff --git a/test/pack/unload_test.lua b/test/pack/unload_test.lua new file mode 100644 index 0000000..ef3e212 --- /dev/null +++ b/test/pack/unload_test.lua @@ -0,0 +1,76 @@ +local pack = require("pack") +local t = require("test") + +---Build a fake plugin tree under a temp dir. `files` maps module names +---(e.g. "foo", "foo.bar") to a string body — the file path is derived by +---swapping `.` for `/` and adding `.lua`. Cleanup is queued so the dir +---is removed after the current test. +---@param files table +---@return string plugin_path +local function fake_plugin(files) + local dir = vim.fn.tempname() + vim.fn.mkdir(dir, "p") + for module, body in pairs(files) do + local rel = module:gsub("%.", "/") .. ".lua" + t.write(dir, "lua/" .. rel, body) + end + t.defer(function() + vim.fn.delete(dir, "rf") + end) + return dir +end + +---Pre-populate `package.loaded` and queue cleanup so the entry doesn't +---leak across tests. +local function preload(name) + package.loaded[name] = { _marker = name } + t.defer(function() + package.loaded[name] = nil + end) +end + +t.test("unload clears top-level module", function() + local dir = fake_plugin({ foo = "return {}" }) + preload("foo") + pack.unload(dir) + t.eq(package.loaded.foo, nil, "foo should be uncached") +end) + +t.test("unload clears submodules via dotted names", function() + local dir = fake_plugin({ + foo = "return {}", + ["foo.bar"] = "return {}", + ["foo.baz.qux"] = "return {}", + }) + preload("foo") + preload("foo.bar") + preload("foo.baz.qux") + pack.unload(dir) + t.eq(package.loaded.foo, nil, "foo cleared") + t.eq(package.loaded["foo.bar"], nil, "foo.bar cleared") + t.eq(package.loaded["foo.baz.qux"], nil, "foo.baz.qux cleared") +end) + +t.test("unload leaves unrelated modules untouched", function() + local dir = fake_plugin({ foo = "return {}" }) + preload("foo") + preload("unrelated") + preload("foo_neighbor") + pack.unload(dir) + t.eq(package.loaded.foo, nil, "foo cleared") + t.truthy(package.loaded.unrelated, "unrelated kept") + t.truthy(package.loaded.foo_neighbor, "foo_neighbor kept") +end) + +t.test("unload handles init.lua submodules", function() + local dir = fake_plugin({ ["pkg.init"] = "return {}" }) + preload("pkg") + pack.unload(dir) + t.eq(package.loaded.pkg, nil, "pkg cleared via init.lua") +end) + +t.test("unload on non-existent path is a no-op", function() + preload("foo") + pack.unload("/nonexistent/path/to/nowhere") + t.truthy(package.loaded.foo, "foo kept") +end) diff --git a/test/run.sh b/test/run.sh deleted file mode 100755 index 849c270..0000000 --- a/test/run.sh +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env bash -set -u - -usage() { - cat <&2 - exit_code=2 - continue - fi - - for f in "${files[@]}"; do - out=$(TEST_FILE_LABEL="$f" nvim --headless --clean \ - -c "set rtp+=$ROOT" \ - -c "lua package.path = package.path .. ';$ROOT/test/?.lua'" \ - -c "luafile $f" \ - -c "qa!" \ - 2>&1) - rc=$? - # Split the per-file results from any output. - results_line=$(printf '%s\n' "$out" | grep '^RESULTS ' | tail -1) - printf '%s\n' "$out" | grep -v '^RESULTS ' - if [[ -n "$results_line" ]]; then - read -r _ p f_count <<<"$results_line" - total_passed=$((total_passed + p)) - total_failed=$((total_failed + f_count)) - fi - if [[ $rc -ne 0 ]]; then - exit_code=$rc - fi - done -done - -printf '\n' -if [[ $total_failed -gt 0 ]]; then - printf '%s%d passed%s, %s%d failed%s\n' \ - "$green" "$total_passed" "$reset" \ - "$red" "$total_failed" "$reset" -else - printf '%s%d passed%s\n' "$green" "$total_passed" "$reset" -fi - -exit "$exit_code" diff --git a/test/runner.lua b/test/runner.lua new file mode 100644 index 0000000..f2a3825 --- /dev/null +++ b/test/runner.lua @@ -0,0 +1,82 @@ +local cfg = vim.fn.stdpath("config") +package.path = package.path + .. (";" .. cfg .. "/lua/?.lua") + .. (";" .. cfg .. "/lua/?/init.lua") + .. (";" .. cfg .. "/?.lua") + .. (";" .. cfg .. "/?/init.lua") + +local opt = vim.fn.stdpath("data") .. "/site/pack/core/opt" +for _, lua_dir in ipairs(vim.fn.glob(opt .. "/*/lua", false, true)) do + package.path = package.path + .. (";" .. lua_dir .. "/?.lua") + .. (";" .. lua_dir .. "/?/init.lua") +end + +local t = require("test") + +local function gather(target) + local abs = vim.fn.fnamemodify(target, ":p") + if vim.fn.isdirectory(abs) == 1 then + return vim.fn.globpath(abs, "**/*_test.lua", false, true) + end + if vim.fn.filereadable(abs) == 1 then + return { abs } + end +end + +local args = arg or {} +local targets = #args > 0 and args or { cfg .. "/test" } + +local files = {} +local resolve_failed = false +for _, target in ipairs(targets) do + local matched = gather(target) + if matched then + for _, f in ipairs(matched) do + table.insert(files, f) + end + else + io.stderr:write("no such test target: " .. target .. "\n") + resolve_failed = true + end +end +table.sort(files) + +local total_passed, total_failed = 0, 0 +for _, f in ipairs(files) do + f = f --[[@as string]] + local label = f:sub(1, #cfg + 1) == cfg .. "/" and f:sub(#cfg + 2) or f + t.start_file(label) + local ok, err = pcall(dofile, f) + if not ok then + t.test("(load)", function() + error(err, 0) + end) + end + local p, fl = t.report() + total_passed = total_passed + p + total_failed = total_failed + fl +end + +local function color(code, str) + if vim.env.TEST_COLOR == "1" then + return string.format("\27[%sm%s\27[0m", code, str) + end + return str +end + +io.stdout:write("\n") +if total_failed > 0 then + io.stdout:write( + string.format( + "%s, %s\n", + color("32", total_passed .. " passed"), + color("31", total_failed .. " failed") + ) + ) + os.exit(1) +end +io.stdout:write(color("32", total_passed .. " passed") .. "\n") +if resolve_failed then + os.exit(2) +end