From e3f98dbff3b28d5f2623698a1ae208b67cc2958c Mon Sep 17 00:00:00 2001 From: eumis Date: Fri, 20 Jun 2025 17:26:01 +0200 Subject: [PATCH 1/2] initial commit --- .github/workflows/ci.yml | 33 ++++ .gitignore_1 | 1 + Makefile | 2 + README.md | 48 +++++ local.lua | 5 + lua/tasks/init.lua | 205 ++++++++++++++++++++++ lua/tests/tasks_spec.lua | 367 +++++++++++++++++++++++++++++++++++++++ plugin/tasks.lua | 1 + scripts/minimal_init.vim | 4 + 9 files changed, 666 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore_1 create mode 100644 Makefile create mode 100644 README.md create mode 100644 local.lua create mode 100644 lua/tasks/init.lua create mode 100644 lua/tests/tasks_spec.lua create mode 100644 plugin/tasks.lua create mode 100644 scripts/minimal_init.vim diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0fbd5b5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: [push, pull_request] + +jobs: + unit_tests: + name: unit tests + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # os: [ubuntu-22.04, macos-latest, windows-2022] + # rev: [nightly, v0.9.5, v0.10.0] + os: [windows-2022] + rev: [v0.10.0] + + steps: + - uses: actions/checkout@v4 + + - uses: rhysd/action-setup-vim@v1 + with: + neovim: true + version: ${{ matrix.rev }} + + - name: Prepare + run: | + git clone --depth 1 https://github.com/nvim-lua/plenary.nvim ../plenary.nvim + + - name: Run tests + shell: bash + run: | + nvim --version + make test diff --git a/.gitignore_1 b/.gitignore_1 new file mode 100644 index 0000000..8ad8db1 --- /dev/null +++ b/.gitignore_1 @@ -0,0 +1 @@ +local.lua diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..df2f6af --- /dev/null +++ b/Makefile @@ -0,0 +1,2 @@ +test: + nvim --headless --noplugin -u "scripts/minimal_init.vim" -c "PlenaryBustedDirectory lua/tests/ { minimal_init = './scripts/minimal_init.vim' }" diff --git a/README.md b/README.md new file mode 100644 index 0000000..6675433 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# tasks.nvim + +Run shell commands(tasks) in dedicated terminal windows. + +## Installation + +- neovim required +- install using your favorite plugin manager + +[lazy.nvim](https://github.com/folke/lazy.nvim) +```lua +{ + "eumis/tasks.nvim" +} +``` + +[mini.deps](https://github.com/echasnovski/mini.nvim/blob/main/readmes/mini-deps.md) +```lua +add({ + source = "eumis/tasks.nvim" +}) +``` + +[packer](https://github.com/wbthomason/packer.nvim) +```lua +use { + "eumis/tasks.nvim" +} +``` + +## Usage + +```lua +local tasks = require "tasks" + +-- Add tasks +tasks.add("test all", "nvim --no-plugin --headless -c 'PlenaryBustedDirectory lua/tests'") +tasks.add("test current file", function(bufnr) return "nvim --headless -c 'PlenaryBustedFile " .. vim.fn.expand("#" .. bufnr) .. "'" end) + +-- Run tasks +tasks.run("test all") + +-- Open/close list of tasks +tasks.toggle_list() +``` + +## Config + diff --git a/local.lua b/local.lua new file mode 100644 index 0000000..dad09bc --- /dev/null +++ b/local.lua @@ -0,0 +1,5 @@ +local tasks = require "task" +tasks.add("test all", "nvim --headless -c 'PlenaryBustedDirectory lua/tests'") +tasks.add("test current file", function(bufnr) + return "nvim --headless -c 'PlenaryBustedFile " .. vim.fn.expand("#" .. bufnr) .. "'" +end) diff --git a/lua/tasks/init.lua b/lua/tasks/init.lua new file mode 100644 index 0000000..11ae6ff --- /dev/null +++ b/lua/tasks/init.lua @@ -0,0 +1,205 @@ +local M = {} + +---@alias cmd string | string[] | fun(bufnr: integer): (string | string[]) + +---@class TaskParams +---@field cwd? string + +---@class Task +---@field name string +---@field cmd cmd +---@field cwd? string +---@field buf integer +---@field win integer +---@field sort_order integer +---@field channel integer + +---@class Options +---@field run_keys? string[] +---@field open_keys? string[] +---@field get_list_win_config? fun(): vim.api.keyset.win_config +---@field get_task_win_config? fun(): vim.api.keyset.win_config + +local state = { + list_buf = -1, + list_win = -1, + current_buf = -1, + ---@type {[string]: Task} + tasks = {}, + tasks_count = 0 +} + +local function get_float_win_config() + local width = math.floor(vim.o.columns * 0.8) + local height = math.floor(vim.o.lines * 0.8) + local col = math.floor((vim.o.columns - width) / 2) + local row = math.floor((vim.o.lines - height) / 2) + return { + relative = "editor", + width = width, + height = height, + col = col, + row = row, + style = "minimal", -- No borders or extra UI elements + border = "rounded", + } +end + +---@type Options +M.opts = { + run_keys = { "r", "" }, + open_keys = { "o" }, + get_list_win_config = get_float_win_config, + get_task_win_config = get_float_win_config +} + +---@param name string +---@param cmd cmd +---@param params? TaskParams +function M.add(name, cmd, params) + params = params or {} + local existing = state.tasks[name] + local new_task = { + name = name, + cmd = cmd, + cwd = params.cwd, + buf = -1, + win = -1, + sort_order = state.tasks_count, + channel = -1 + } + if existing ~= nil then + new_task.buf = existing.buf + new_task.win = existing.win + new_task.sort_order = existing.sort_order + new_task.channel = existing.channel + end + state.tasks_count = state.tasks_count + 1 + state.tasks[name] = new_task + + return new_task +end + +---@param name string +---@return Task? +function M.get(name) + return state.tasks[name] +end + +---@return Task[] +function M.get_all() + local tasks = {} + for _, task in pairs(state.tasks) do + table.insert(tasks, task) + end + table.sort(tasks, function(left, right) return left.sort_order < right.sort_order end) + return tasks +end + +---@param task Task +local function ensure_task_buffer(task) + if vim.api.nvim_win_is_valid(task.win) then return end + + if not vim.api.nvim_buf_is_valid(task.buf) then + task.buf = vim.api.nvim_create_buf(false, true) + end + task.win = vim.api.nvim_open_win(task.buf, true, M.opts.get_task_win_config()) + if vim.bo[task.buf].buftype ~= "terminal" then + vim.cmd.terminal() + task.channel = vim.bo[task.buf].channel + if task.cwd ~= nil then + vim.fn.chansend(task.channel, { "cd " .. task.cwd, "" }) + end + end +end + +---@param name string +function M.open(name) + ensure_task_buffer(state.tasks[name]) +end + +---@param name string +---@param bufnr? integer +function M.run(name, bufnr) + local task = state.tasks[name] + if bufnr == nil then bufnr = vim.api.nvim_get_current_buf() end + ensure_task_buffer(task) + local cmd = task.cmd + if type(cmd) == 'function' then + cmd = cmd(bufnr) + end + if type(cmd) == 'table' then + cmd = table.concat(cmd, ' && ') + end + + vim.fn.chansend(task.channel, { cmd, "" }) + vim.cmd("normal G") +end + +function M.open_list() + if vim.api.nvim_win_is_valid(state.list_win) then return end + + state.current_buf = vim.api.nvim_get_current_buf() + if not vim.api.nvim_buf_is_valid(state.list_buf) then + state.list_buf = vim.api.nvim_create_buf(false, true) + for _, key in ipairs(M.opts.run_keys) do + vim.api.nvim_buf_set_keymap(state.list_buf, 'n', key, '', { + callback = function() + local name = vim.api.nvim_get_current_line() + M.toggle_list() + M.run(name, state.current_buf) + end + }) + end + + for _, key in ipairs(M.opts.open_keys) do + vim.api.nvim_buf_set_keymap(state.list_buf, 'n', key, '', { + callback = function() + local name = vim.api.nvim_get_current_line() + M.toggle_list() + M.open(name) + end + }) + end + end + + local tasks = {} + for name, _ in pairs(state.tasks) do + table.insert(tasks, name) + end + table.sort(tasks, function(left, right) return state.tasks[left].sort_order < state.tasks[right].sort_order end) + vim.api.nvim_buf_set_lines(state.list_buf, 0, -1, false, tasks) + + state.list_win = vim.api.nvim_open_win(state.list_buf, true, M.opts.get_list_win_config()) +end + +function M.close_list() + if vim.api.nvim_win_is_valid(state.list_win) then + vim.api.nvim_win_hide(state.list_win) + end +end + +function M.toggle_list() + if vim.api.nvim_win_is_valid(state.list_win) then + M.close_list() + else + M.open_list() + end +end + +vim.api.nvim_create_user_command('TasksRun', function(opts) M.run(opts.fargs[1]) end, { nargs = 1 }) +vim.api.nvim_create_user_command('TasksOpen', function(opts) M.open(opts.fargs[1]) end, { nargs = 1 }) +vim.api.nvim_create_user_command('TasksOpenList', function() M.open_list() end, {}) +vim.api.nvim_create_user_command('TasksCloseList', function() M.close_list() end, {}) +vim.api.nvim_create_user_command('TasksToggleList', function() M.toggle_list() end, {}) + +---@param opts Options +function M.setup(opts) + for key, value in pairs(opts) do + if value ~= nil then + M.opts[key] = value + end + end +end + +return M diff --git a/lua/tests/tasks_spec.lua b/lua/tests/tasks_spec.lua new file mode 100644 index 0000000..db281a2 --- /dev/null +++ b/lua/tests/tasks_spec.lua @@ -0,0 +1,367 @@ +---@diagnostic disable: need-check-nil + +local assert = require "luassert" +local reload = require "plenary.reload" + +local M = { + tasks = require "tasks" +} + +local chansend_mock = { + original = vim.fn.chansend, + calls = {}, +} + +function chansend_mock.chansend(id, data) + if chansend_mock.calls[id] == nil then + chansend_mock.calls[id] = {} + end + table.insert(chansend_mock.calls[id], data) +end + +local function setup() + reload.reload_module("tasks") + M.tasks = require "tasks" + vim.fn.chansend = chansend_mock.chansend +end + +local function cleanup() + vim.fn.chansend = chansend_mock.original + chansend_mock.calls = {} +end + +---@param task_name string +---@return string +local function get_echo_output(task_name) + return "echo output for " .. task_name +end + +---@param name string +---@param add boolean? +local function create_echo_task(name, add) + local task = { + name = name, + cmd = "echo \"" .. get_echo_output(name) .. "\"" + } + if (add == true) then + return M.tasks.add(task.name, task.cmd) + end + return task +end + +---@param expected Task +---@param actual Task? +local function assert_task_equal(expected, actual) + assert.is.Not.Nil(actual, "task") + assert.are.equal(expected.name, actual.name, "task name") + assert.are.equal(expected.cmd, actual.cmd, "task cmd") +end + +---@param task Task +local function assert_task_view_opened(task) + assert.are.equal(vim.api.nvim_get_current_win(), task.win, "task win") + assert.are.equal(vim.api.nvim_get_current_buf(), task.buf, "task buf") + assert.are.equal("terminal", vim.bo[task.buf].buftype) +end + +---@param cmd string +---@param buf? integer +local function assert_cmd_run(cmd, buf) + if buf == nil then buf = vim.api.nvim_get_current_buf() end + local channel_id = vim.api.nvim_buf_get_var(buf, "terminal_job_id") + + local cmd_run = false + if chansend_mock.calls[channel_id] ~= nil then + for _, call_data in ipairs(chansend_mock.calls[channel_id]) do + cmd_run = call_data[1] == cmd and call_data[2] == "" + if cmd_run then break end + end + end + + assert.is.True(cmd_run, cmd .. " is run") +end + +describe("task.add", function() + before_each(setup) + after_each(cleanup) + + it("should add task", function() + local expected = create_echo_task("one") + + local actual = M.tasks.add(expected.name, expected.cmd) + + assert_task_equal(expected, actual) + end) + + it("should add task with sort_order", function() + local test_tasks = { + create_echo_task("one"), + create_echo_task("two"), + create_echo_task("three"), + } + for i, task in ipairs(test_tasks) do + local actual = M.tasks.add(task.name, task.cmd) + assert.are.equal(i - 1, actual.sort_order, "task sort order") + end + end) + + it("should replace task with the same name", function() + local name = "one" + create_echo_task(name, true) + local two = create_echo_task(name) + two.cmd = "echo two" + local created = M.tasks.add(two.name, two.cmd) + + local actual = M.tasks.get(name) + + assert.are.same(created, actual, "task") + assert.are.equal(name, actual.name, "task name") + assert.are.equal(two.cmd, actual.cmd, "task cmd") + assert.are.equal(0, actual.sort_order, "task sort order") + end) +end) + +describe("task.get", function() + before_each(setup) + after_each(cleanup) + + it("should return nil for not existing task", function() + assert.is.Nil(M.tasks.get("one"), "task") + end) + + it("should return task", function() + local expected = create_echo_task("one") + M.tasks.add(expected.name, expected.cmd) + + local actual = M.tasks.get(expected.name) + + assert_task_equal(expected, actual) + end) + + it("all should return all tasks", function() + local test_tasks = { + create_echo_task("one", true), + create_echo_task("two", true), + create_echo_task("three", true), + } + + local actual_tasks = M.tasks.get_all() + + for i, task in ipairs(test_tasks) do + local actual = actual_tasks[i] + assert_task_equal(task, actual) + assert.are.equal(i - 1, actual.sort_order, "task sort order") + end + end) +end) + +local open_cases = { + ["task.open"] = function(name) M.tasks.open(name) end, + ["TasksOpen"] = function(name) vim.cmd("TasksOpen " .. name) end +} +for name, act in pairs(open_cases) do + describe(name, function() + before_each(setup) + after_each(cleanup) + + it("should create win and buffer", function() + local task = create_echo_task("one", true) + + act(task.name) + + assert_task_view_opened(task) + end) + + it("should open existing win and buffer", function() + local task = create_echo_task("one", true) + M.tasks.open(task.name) + local win = task.win + local buf = task.buf + + act(task.name) + + assert_task_view_opened(task) + assert.are.equal(win, task.win, "task win") + assert.are.equal(buf, task.buf, "task buf") + end) + + it("should cd to cwd", function() + local cwd = "test/cwd" + local task = M.tasks.add("test", "echo '1234'", { cwd = "test/cwd" }) + + act(task.name) + + assert_cmd_run("cd " .. cwd) + end) + end) +end + +local run_cases = { + ["task.run"] = function(name) M.tasks.run(name) end, + ["TasksRun"] = function(name) vim.cmd("TasksRun " .. name) end +} +for name, act in pairs(run_cases) do + describe(name, function() + before_each(setup) + after_each(cleanup) + + local run_tasks = { + { name = "cmd", cmd = "echo \"cmd task\"", output = "echo \"cmd task\"" }, + { name = "fun", cmd = function() return "echo \"function task\"" end, output = "echo \"function task\"" }, + } + for _, task_data in ipairs(run_tasks) do + it("should run " .. task_data.name .. " task command", function() + local task = M.tasks.add(task_data.name, task_data.cmd) + + act(task_data.name) + + assert_task_view_opened(task) + assert_cmd_run(task_data.output) + end) + end + + local multiple_run_tasks = { + { name = "cmd", cmd = { "echo \"cmd task\"", "echo \"second command\"" }, expected_cmd = "echo \"cmd task\" && echo \"second command\"" }, + { name = "fun", cmd = function() return { "echo \"function task\"", "echo \"second function task\"" } end, expected_cmd = "echo \"function task\" && echo \"second function task\"" }, + } + for _, task_data in ipairs(multiple_run_tasks) do + it("should run " .. task_data.name .. " task multiple commands", function() + local task = M.tasks.add(task_data.name, task_data.cmd) + + act(task_data.name) + + assert_task_view_opened(task) + assert_cmd_run(task_data.expected_cmd) + end) + end + end) +end + +local open_list_cases = { + ["task.open_list"] = function() M.tasks.open_list() end, + ["TasksOpenList"] = function() vim.cmd("TasksOpenList") end +} +for name, act in pairs(open_list_cases) do + describe(name, function() + before_each(setup) + after_each(cleanup) + + it("should open task list", function() + local test_tasks = { + create_echo_task("one", true), + create_echo_task("two", true), + create_echo_task("three", true) + } + + act() + + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + for i, task in ipairs(test_tasks) do + assert.are.equal(task.name, lines[i], "task name") + end + end) + end) +end + +local close_list_cases = { + ["task.close_list"] = function() M.tasks.close_list() end, + ["TasksCloseList"] = function() vim.cmd("TasksCloseList") end +} +for name, act in pairs(close_list_cases) do + describe(name, function() + before_each(setup) + after_each(cleanup) + + it("should close task list", function() + M.tasks.open_list() + local list_win = vim.api.nvim_get_current_win() + + act() + + assert.is.Not.True(vim.api.nvim_win_is_valid(list_win), "task win valid") + end) + end) +end + +local toggle_list_cases = { + ["task.toggle_list"] = function() M.tasks.toggle_list() end, + ["TasksToggleList"] = function() vim.cmd("TasksToggleList") end +} +for name, act in pairs(toggle_list_cases) do + describe(name, function() + before_each(setup) + after_each(cleanup) + + it("should open task list", function() + M.tasks.close_list() + local test_tasks = { + create_echo_task("one", true), + create_echo_task("two", true), + create_echo_task("three", true) + } + + act() + + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + for i, task in ipairs(test_tasks) do + assert.are.equal(task.name, lines[i], "task name") + end + end) + + it("should close task list", function() + M.tasks.open_list() + local list_win = vim.api.nvim_get_current_win() + + act() + + assert.is.Not.True(vim.api.nvim_win_is_valid(list_win), "task win valid") + end) + end) +end + +describe("task list", function() + local run_keys = { "r", "e" } + local open_keys = { "o", "s" } + local test_tasks = {} + + before_each(function() + setup() + M.tasks.setup { + run_keys = run_keys, + open_keys = open_keys + } + test_tasks = { + create_echo_task("one", true), + create_echo_task("two", true), + create_echo_task("three", true) + } + end) + after_each(cleanup) + + for _, key in ipairs(run_keys) do + for i = 1, 3 do + it("should run task[" .. tostring(i) .. "] on " .. key, function() + M.tasks.open_list() + + vim.api.nvim_win_set_cursor(0, { i, 1 }) + vim.cmd("normal " .. key) + + assert_task_view_opened(test_tasks[i]) + assert_cmd_run(test_tasks[i].cmd) + end) + end + end + + for _, key in ipairs(open_keys) do + for i = 1, 3 do + it("should open task[" .. tostring(i) .. "] on " .. key, function() + M.tasks.open_list() + + vim.api.nvim_win_set_cursor(0, { i, 1 }) + vim.cmd("normal " .. key) + + assert_task_view_opened(test_tasks[i]) + end) + end + end +end) diff --git a/plugin/tasks.lua b/plugin/tasks.lua new file mode 100644 index 0000000..a706a71 --- /dev/null +++ b/plugin/tasks.lua @@ -0,0 +1 @@ +require "tasks" diff --git a/scripts/minimal_init.vim b/scripts/minimal_init.vim new file mode 100644 index 0000000..23618bf --- /dev/null +++ b/scripts/minimal_init.vim @@ -0,0 +1,4 @@ +set noswapfile +set rtp+=. +set rtp+=../plenary.nvim +runtime! plugin/plenary.vim From 5ba4dc0390e8e0cf1ea153767c299991a1c81a68 Mon Sep 17 00:00:00 2001 From: eumis Date: Fri, 20 Jun 2025 17:27:00 +0200 Subject: [PATCH 2/2] added os to ci --- .github/workflows/ci.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0fbd5b5..c508e37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,10 +9,8 @@ jobs: strategy: fail-fast: false matrix: - # os: [ubuntu-22.04, macos-latest, windows-2022] - # rev: [nightly, v0.9.5, v0.10.0] - os: [windows-2022] - rev: [v0.10.0] + os: [ubuntu-22.04, macos-latest, windows-2022] + rev: [nightly, v0.9.5, v0.10.0] steps: - uses: actions/checkout@v4