From f87f3ea322b1111e1929d149224ff736c8390db3 Mon Sep 17 00:00:00 2001 From: Steven Arcangeli Date: Sun, 27 Aug 2023 18:15:04 -0700 Subject: test: add a test suite --- .github/workflows/tests.yml | 23 +++++ .gitignore | 2 + lua/conform/init.lua | 2 +- lua/conform/runner.lua | 37 +++++-- run_tests.sh | 24 +++++ tests/fake_formatter.sh | 17 ++++ tests/minimal_init.lua | 5 + tests/runner_spec.lua | 243 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_util.lua | 34 +++++++ 9 files changed, 376 insertions(+), 11 deletions(-) create mode 100755 run_tests.sh create mode 100755 tests/fake_formatter.sh create mode 100644 tests/minimal_init.lua create mode 100644 tests/runner_spec.lua create mode 100644 tests/test_util.lua diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ca71ef2..7e5b6e0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -40,6 +40,28 @@ jobs: version: v0.15.2 args: --check . + run_tests: + strategy: + matrix: + include: + - nvim_tag: v0.8.3 + - nvim_tag: v0.9.1 + + name: Run tests + runs-on: ubuntu-22.04 + env: + NVIM_TAG: ${{ matrix.nvim_tag }} + steps: + - uses: actions/checkout@v3 + + - name: Install Neovim and dependencies + run: | + bash ./.github/workflows/install_nvim.sh + + - name: Run tests + run: | + bash ./run_tests.sh + release: name: release @@ -48,6 +70,7 @@ jobs: - luacheck - stylua - typecheck + - run_tests runs-on: ubuntu-22.04 steps: - uses: google-github-actions/release-please-action@v3 diff --git a/.gitignore b/.gitignore index d818abb..b29818a 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,5 @@ luac.out .direnv/ .testenv/ doc/tags +tests/testfile.txt +tests/fake_formatter_output diff --git a/lua/conform/init.lua b/lua/conform/init.lua index 15c4317..5b8bd5b 100644 --- a/lua/conform/init.lua +++ b/lua/conform/init.lua @@ -174,7 +174,7 @@ M.list_formatters = function(bufnr) for formatter in pairs(formatters) do local info = M.get_formatter_info(formatter) if info.available then - table.insert(all_info, assert(info)) + table.insert(all_info, info) if not run_options.run_all_formatters then break end diff --git a/lua/conform/runner.lua b/lua/conform/runner.lua index 83647a2..4c8fea7 100644 --- a/lua/conform/runner.lua +++ b/lua/conform/runner.lua @@ -6,7 +6,7 @@ local M = {} ---@param ctx conform.Context ---@param config conform.FormatterConfig -local function build_cmd(ctx, config) +M.build_cmd = function(ctx, config) local command = config.command if type(command) == "function" then command = command(ctx) @@ -22,7 +22,7 @@ local function build_cmd(ctx, config) if v == "$FILENAME" then v = ctx.filename elseif v == "$DIRNAME" then - v = vim.fs.dirname(ctx.filename) + v = ctx.dirname end table.insert(cmd, v) end @@ -37,23 +37,38 @@ local function apply_format(bufnr, original_lines, new_lines) local restore = util.save_win_positions(bufnr) local original_text = table.concat(original_lines, "\n") - -- Trim off the final newline because the original lines won't have it - -- and we want the diffs to agree if the file is unchanged + -- Trim off the final newline from the formatted text because that is baked in to + -- the vim lines representation if new_lines[#new_lines] == "" then new_lines[#new_lines] = nil end local new_text = table.concat(new_lines, "\n") local indices = vim.diff(original_text, new_text, { result_type = "indices", - algorithm = "minimal", + algorithm = "histogram", }) assert(indices) for i = #indices, 1, -1 do local start_a, count_a, start_b, count_b = unpack(indices[i]) -- When count_a is 0, the diff is an insert after the line if count_a == 0 then + -- This happens when the first line is blank and we're inserting text after it + if start_a == 0 then + count_a = 1 + end start_a = start_a + 1 end + + -- If this diff range goes *up to* the last line in the original file, *and* the last line + -- after that is just an empty space, then the diff range here was calculated to include that + -- final newline, so we should bump up the count_a to include it + if (start_a + count_a) == #original_lines and original_lines[#original_lines] == "" then + count_a = count_a + 1 + end + -- Same logic for the new lines + if (start_b + count_b) == #new_lines and new_lines[#new_lines] == "" then + count_b = count_b + 1 + end local replacement = util.tbl_slice(new_lines, start_b, start_b + count_b - 1) vim.api.nvim_buf_set_lines(bufnr, start_a - 1, start_a - 1 + count_a, true, replacement) end @@ -69,7 +84,7 @@ end local function run_formatter(bufnr, formatter, input_lines, callback) local config = assert(require("conform").get_formatter_config(formatter.name)) local ctx = M.build_context(bufnr, config) - local cmd = build_cmd(ctx, config) + local cmd = M.build_cmd(ctx, config) local cwd = nil if config.cwd then cwd = config.cwd(ctx) @@ -125,9 +140,11 @@ local function run_formatter(bufnr, formatter, input_lines, callback) log.error("Formatter %s exited with code %d", formatter.name, code) log.warn("Formatter %s stdout:", formatter.name, stdout) log.warn("Formatter %s stderr:", formatter.name, stderr) - callback( - string.format("Formatter '%s' error: %s", formatter.name, table.concat(stderr, "\n")) - ) + local stderr_str + if stderr then + stderr_str = table.concat(stderr, "\n") + end + callback(string.format("Formatter '%s' error: %s", formatter.name, stderr_str)) end end, }) @@ -223,7 +240,7 @@ M.format_async = function(bufnr, formatters, callback) jid = run_formatter(bufnr, formatter, input_lines, function(err, output) if err then -- Only display the error if the job wasn't canceled - if jid == vim.b[bufnr].conform_jid then + if vim.api.nvim_buf_is_valid(bufnr) and jid == vim.b[bufnr].conform_jid then vim.notify(err, vim.log.levels.ERROR) end if callback then diff --git a/run_tests.sh b/run_tests.sh new file mode 100755 index 0000000..98b4fa7 --- /dev/null +++ b/run_tests.sh @@ -0,0 +1,24 @@ +#!/bin/bash +set -e + +mkdir -p ".testenv/config/nvim" +mkdir -p ".testenv/data/nvim" +mkdir -p ".testenv/state/nvim" +mkdir -p ".testenv/run/nvim" +mkdir -p ".testenv/cache/nvim" +PLUGINS=".testenv/data/nvim/site/pack/plugins/start" + +if [ ! -e "$PLUGINS/plenary.nvim" ]; then + git clone --depth=1 https://github.com/nvim-lua/plenary.nvim.git "$PLUGINS/plenary.nvim" +else + (cd "$PLUGINS/plenary.nvim" && git pull) +fi + +XDG_CONFIG_HOME=".testenv/config" \ + XDG_DATA_HOME=".testenv/data" \ + XDG_STATE_HOME=".testenv/state" \ + XDG_RUNTIME_DIR=".testenv/run" \ + XDG_CACHE_HOME=".testenv/cache" \ + nvim --headless -u tests/minimal_init.lua \ + -c "PlenaryBustedDirectory ${1-tests} { minimal_init = './tests/minimal_init.lua' }" +echo "Success" diff --git a/tests/fake_formatter.sh b/tests/fake_formatter.sh new file mode 100755 index 0000000..a060a4c --- /dev/null +++ b/tests/fake_formatter.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +set -e + +if [ -e "tests/fake_formatter_output" ]; then + cat tests/fake_formatter_output +else + cat +fi + +if [ "$1" = "--fail" ]; then + echo "failure" >&2 + exit 1 +elif [ "$1" = "--timeout" ]; then + sleep 4 +fi + diff --git a/tests/minimal_init.lua b/tests/minimal_init.lua new file mode 100644 index 0000000..262d9ec --- /dev/null +++ b/tests/minimal_init.lua @@ -0,0 +1,5 @@ +vim.cmd([[set runtimepath+=.]]) + +vim.o.swapfile = false +vim.bo.swapfile = false +require("tests.test_util").reset_editor() diff --git a/tests/runner_spec.lua b/tests/runner_spec.lua new file mode 100644 index 0000000..016c8a4 --- /dev/null +++ b/tests/runner_spec.lua @@ -0,0 +1,243 @@ +require("plenary.async").tests.add_to_env() +local test_util = require("tests.test_util") +local conform = require("conform") +local runner = require("conform.runner") + +describe("runner", function() + after_each(function() + test_util.reset_editor() + end) + + it("resolves config function", function() + conform.formatters.test = function() + return { + meta = { url = "", description = "" }, + command = "echo", + } + end + local config = assert(conform.get_formatter_config("test")) + assert.are.same({ + meta = { url = "", description = "" }, + command = "echo", + stdin = true, + }, config) + end) + + describe("build_context", function() + it("sets the filename and dirname", function() + vim.cmd.edit({ args = { "README.md" } }) + local bufnr = vim.api.nvim_get_current_buf() + conform.formatters.test = { + meta = { url = "", description = "" }, + command = "echo", + } + local config = assert(conform.get_formatter_config("test")) + local ctx = runner.build_context(0, config) + local filename = vim.api.nvim_buf_get_name(bufnr) + assert.are.same({ + buf = bufnr, + filename = filename, + dirname = vim.fs.dirname(filename), + }, ctx) + end) + + it("sets temp file when stdin = false", function() + vim.cmd.edit({ args = { "README.md" } }) + local bufnr = vim.api.nvim_get_current_buf() + conform.formatters.test = { + meta = { url = "", description = "" }, + command = "echo", + stdin = false, + } + local config = assert(conform.get_formatter_config("test")) + local ctx = runner.build_context(0, config) + local bufname = vim.api.nvim_buf_get_name(bufnr) + local dirname = vim.fs.dirname(bufname) + assert.equal(bufnr, ctx.buf) + assert.equal(dirname, ctx.dirname) + assert.truthy(ctx.filename:match(dirname .. "/.conform.%d+.README.md$")) + end) + end) + + describe("build_cmd", function() + it("replaces $FILENAME in args", function() + vim.cmd.edit({ args = { "README.md" } }) + local bufnr = vim.api.nvim_get_current_buf() + conform.formatters.test = { + meta = { url = "", description = "" }, + command = "echo", + args = { "$FILENAME" }, + } + local config = assert(conform.get_formatter_config("test")) + local ctx = runner.build_context(0, config) + local cmd = runner.build_cmd(ctx, config) + assert.are.same({ "echo", vim.api.nvim_buf_get_name(bufnr) }, cmd) + end) + + it("replaces $DIRNAME in args", function() + vim.cmd.edit({ args = { "README.md" } }) + local bufnr = vim.api.nvim_get_current_buf() + conform.formatters.test = { + meta = { url = "", description = "" }, + command = "echo", + args = { "$DIRNAME" }, + } + local config = assert(conform.get_formatter_config("test")) + local ctx = runner.build_context(0, config) + local cmd = runner.build_cmd(ctx, config) + assert.are.same({ "echo", vim.fs.dirname(vim.api.nvim_buf_get_name(bufnr)) }, cmd) + end) + + it("resolves arg function", function() + vim.cmd.edit({ args = { "README.md" } }) + local bufnr = vim.api.nvim_get_current_buf() + conform.formatters.test = { + meta = { url = "", description = "" }, + command = "echo", + args = function() + return { "--stdin" } + end, + } + local config = assert(conform.get_formatter_config("test")) + local ctx = runner.build_context(0, config) + local cmd = runner.build_cmd(ctx, config) + assert.are.same({ "echo", "--stdin" }, cmd) + end) + end) + + describe("e2e", function() + before_each(function() + conform.formatters.test = { + meta = { url = "", description = "" }, + command = "tests/fake_formatter.sh", + } + end) + + ---@param buf_content string + ---@param expected string + ---@param opts? table + local function run_formatter(buf_content, expected, opts) + local bufnr = vim.fn.bufadd("testfile") + vim.fn.bufload(bufnr) + vim.api.nvim_set_current_buf(bufnr) + local lines = vim.split(buf_content, "\n", { plain = true }) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, lines) + vim.bo[bufnr].modified = false + local expected_lines = vim.split(expected, "\n", { plain = true }) + test_util.set_formatter_output(expected_lines) + conform.format(vim.tbl_extend("force", opts or {}, { formatters = { "test" } })) + return expected_lines + end + + ---@param buf_content string + ---@param new_content string + ---@param expected? string[] + local function run_formatter_test(buf_content, new_content, expected) + local lines = run_formatter(buf_content, new_content) + assert.are.same(expected or lines, vim.api.nvim_buf_get_lines(0, 0, -1, false)) + end + + it("sets the correct output", function() + run_formatter_test( + [[ + if true { + print("hello") + }]], + [[ + if true { + print("hello") + }]] + ) + run_formatter_test( + [[ + if true { + print("hello") + }]], + [[ + if true { + print("goodbye") + }]] + ) + run_formatter_test( + [[ + if true { + print("hello") + }]], + [[ + if true { + print("hello world") + print("hello world") + print("hello world") + }]] + ) + run_formatter_test( + [[ +print("a") +print("b") +print("c") + ]], + [[ +print("c") +print("b") +print("a") + ]] + ) + run_formatter_test("hello\ngoodbye", "hello\n\n\ngoodbye", { "hello", "", "", "goodbye" }) + run_formatter_test("hello", "hello\ngoodbye", { "hello", "goodbye" }) + run_formatter_test("", "hello", { "hello" }) + run_formatter_test("\nfoo", "\nhello\nfoo", { "", "hello", "foo" }) + run_formatter_test("hello", "hello\n\n", { "hello", "" }) + run_formatter_test("hello", "hello\n", { "hello" }) + assert.falsy(vim.bo.modified) + run_formatter_test("hello\n", "hello", { "hello" }) + run_formatter_test("hello\n ", "hello", { "hello" }) + end) + + it("does not change output if formatter fails", function() + conform.formatters.test.args = { "--fail" } + run_formatter("hello", "goodbye") + assert.are.same({ "hello" }, vim.api.nvim_buf_get_lines(0, 0, -1, false)) + end) + + it("allows nonzero exit codes", function() + conform.formatters.test.args = { "--fail" } + conform.formatters.test.exit_codes = { 0, 1 } + run_formatter_test("hello", "goodbye") + end) + + it("does not format if it times out", function() + conform.formatters.test.args = { "--timeout" } + run_formatter("hello", "goodbye", { timeout_ms = 10 }) + assert.are.same({ "hello" }, vim.api.nvim_buf_get_lines(0, 0, -1, false)) + end) + + it("can format async", function() + run_formatter("hello", "goodbye", { async = true }) + assert.are.same({ "hello" }, vim.api.nvim_buf_get_lines(0, 0, -1, false)) + vim.wait(100) + assert.are.same({ "goodbye" }, vim.api.nvim_buf_get_lines(0, 0, -1, false)) + end) + + it("discards formatting changes if buffer has been concurrently modified", function() + run_formatter("hello", "goodbye", { async = true }) + assert.are.same({ "hello" }, vim.api.nvim_buf_get_lines(0, 0, -1, false)) + vim.api.nvim_buf_set_lines(0, 0, -1, true, { "newcontent" }) + vim.wait(100) + assert.are.same({ "newcontent" }, vim.api.nvim_buf_get_lines(0, 0, -1, false)) + end) + + it("formats on save", function() + conform.setup({ + formatters_by_ft = { ["*"] = { "test" } }, + format_on_save = true, + }) + vim.cmd.edit({ args = { "tests/testfile.txt" } }) + vim.api.nvim_buf_set_lines(0, 0, -1, true, { "hello" }) + test_util.set_formatter_output({ "goodbye" }) + vim.cmd.write() + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + vim.fn.delete("tests/testfile.txt") + assert.are.same({ "goodbye" }, lines) + end) + end) +end) diff --git a/tests/test_util.lua b/tests/test_util.lua new file mode 100644 index 0000000..a225aec --- /dev/null +++ b/tests/test_util.lua @@ -0,0 +1,34 @@ +require("plenary.async").tests.add_to_env() +local conform = require("conform") +local M = {} + +local OUTPUT_FILE = "tests/fake_formatter_output" + +M.reset_editor = function() + vim.cmd.tabonly({ mods = { silent = true } }) + for i, winid in ipairs(vim.api.nvim_tabpage_list_wins(0)) do + if i > 1 then + vim.api.nvim_win_close(winid, true) + end + end + vim.api.nvim_win_set_buf(0, vim.api.nvim_create_buf(false, true)) + for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + conform.formatters = {} + conform.formatters_by_ft = {} + pcall(vim.api.nvim_del_augroup_by_name, "Conform") + if vim.fn.filereadable(OUTPUT_FILE) == 1 then + vim.fn.delete(OUTPUT_FILE) + end +end + +---@param lines string[] +M.set_formatter_output = function(lines) + local content = table.concat(lines, "\n") + local fd = assert(vim.loop.fs_open(OUTPUT_FILE, "w", 420)) -- 0644 + vim.loop.fs_write(fd, content) + vim.loop.fs_close(fd) +end + +return M -- cgit v1.2.3-70-g09d2