A project-local task runner for Neovim. Declare your build, test, run, and
debug tasks once in a TOML file and launch them from inside the editor with
:Tasks — with smart completion and inline diagnostics while you edit the file,
task dependencies, value expressions, quickfix parsing, and a live output window
that streams each task's output.
Warning
Work in progress. The plugin is usable but under active development; the configuration format may still change.
- Features
- Requirements
- Installation
- Quick start
- The tasks file
- Task types
- Shared task options
- Expressions
- Quickfix matchers
- The
:Taskscommand - Task output
- Editing support
- Configuration
- License
- One TOML file per project — tasks live in
tasks.tomlat the project root; the presence of that file is what marks a directory as a project. - Built-in task types — run a program directly (
process), through a shell (shell), group other tasks (composite), or start a debug session (debug, via ezdap.nvim). - Task dependencies — declare
depends_onand run prerequisites insequenceor inparallelbefore the task itself. - Concurrency policies — control what happens when a task is already running
(
wait,restart,refuse,parallel). - Value expressions — interpolate the current file, cwd, environment,
shell output, or interactive prompts into task values with a small
{{ … }}expression language, and define your own reusable inline macros. - Quickfix parsing — turn compiler/linter/test output into a populated quickfix list with a named matcher (GCC, TypeScript, Go, Rust, Python, and more built in).
- Smart editing — the tasks file gets completion, hover, diagnostics, code actions, and formatting as you type.
- Live task output — a bottom split streaming the running task's output, with a per-run log buffer of its progress. With dock.nvim installed, each run gets its own numbered tab instead.
- Neovim ≥ 0.10
- ezdap.nvim — optional, required
only for the
debugtask type. - dock.nvim — optional; when present, task output is shown in the shared dock panel, one tab per run, instead of the plugin's own split.
Using Neovim's built-in plugin manager, vim.pack (Neovim 0.12+; see
:help vim.pack):
vim.pack.add({
-- { src = "https://github.com/mbfoss/ezdap.nvim" }, -- optional, only for `debug` tasks
{ src = "https://github.com/mbfoss/tomltasks.nvim" },
})
-- require("ezdap").setup()
require("tomltasks").setup()Using lazy.nvim:
{
"mbfoss/tomltasks.nvim",
-- optional, only for `debug` tasks:
-- dependencies = { "mbfoss/ezdap.nvim" },
opts = {},
}
opts = {}callsrequire("tomltasks").setup()with the defaults. Replace it with a table to override any configuration value.
-
Create a
tasks.tomlin your project root:[tasks.build] type = "shell" command = "make -j" [tasks.test] type = "process" command = "ctest --output-on-failure" depends_on = ["build"]
-
Run a task:
:Tasks
Pick a task from the list. The output window opens and streams its output. Running
testfirst runsbuild(its dependency), thentest. -
Re-run the last task, or stop a running one:
:Tasks rerun :Tasks stop
While editing tasks.toml you get completion, hover docs, and inline
diagnostics for every field — see Editing support.
Tasks are defined under the [tasks] table, keyed by name. A task's name is
the header key ([tasks.<name>]) — you do not repeat it as a field. Every
task must declare a type.
# Optional: reusable inline expression macros (see “Expressions”).
[expressions]
outdir = "{{ projectdir }}/build"
[tasks.build]
type = "shell"
command = "cmake --build {{ outdir }}"
[tasks.run]
type = "process"
command = "{{ outdir }}/app --verbose"
depends_on = ["build"]The top-level document has just two tables:
| Key | Purpose |
|---|---|
[tasks] |
Task definitions, keyed by name ([tasks.<name>]). Required. |
[expressions] |
Named inline expression macros. Optional. |
Every task shares a common set of options; the fields below are specific to each type.
Runs a command directly, without a shell. A string command is split into argv using POSIX shell-word rules; an array is used verbatim (no splitting, globbing, or shell operators).
[tasks.lint]
type = "process"
command = "eslint src --format unix" # or ["eslint", "src", …]
cwd = "{{ projectdir }}"
env = { NODE_ENV = "development" }
quickfix_matcher = "linter"| Field | Type | Description |
|---|---|---|
command |
string | string[] | Required. Program + args. String is shell-word split; array as-is. |
cwd |
string | Working directory for the command. |
env |
table<string,string> | Environment variables to set. |
clear_env |
boolean | Pass env verbatim instead of merging it onto the current env. |
quickfix_matcher |
string | Name of a quickfix matcher to parse output. |
Runs a command string through the shell, so pipes, globs, redirection, and
&& all work.
[tasks.deploy]
type = "shell"
command = "npm run build && rsync -a dist/ server:/var/www"Fields are the same as process, except command must be a single string
(it is the shell command line).
A task with no command of its own — it exists purely to group other tasks
through its dependencies. Combine with depends_order to run them in sequence
or in parallel.
[tasks.ci]
type = "composite"
depends_on = ["lint", "test", "build"]
depends_order = "sequence"Starts a debug session through ezdap.nvim.
This task type is only available when ezdap.nvim is installed — without it,
tomltasks works normally and simply offers no debug type.
Each debug adapter publishes a set of named profiles — its launch/attach
shapes — that you pick from with profile, then fill that profile's inputs with
parameters. For anything a profile doesn't expose, request_overrides merges
raw fields straight into the debug request.
[tasks.debug-app]
type = "debug"
adapter = "codelldb"
profile = "launch"
parameters = { command = "{{ outdir }}/app --flag", cwd = "{{ projectdir }}" }| Field | Type | Description |
|---|---|---|
adapter |
string | Required. Debug adapter name (e.g. codelldb, delve, debugpy). |
profile |
string | Required. Which of the adapter's named profiles to run (e.g. launch, attach). |
parameters |
table | Values for the selected profile's inputs. Keys depend on adapter/profile. |
request_overrides |
table | Raw request fields, deep-merged over the resolved profile. Advanced escape hatch. |
When ezdap is available, profile completes to the adapter's named profiles
and parameters is completed and validated against the inputs that profile
declares.
These fields are available on every task type.
| Field | Type | Description |
|---|---|---|
type |
string | Required. The task type. |
if_running |
enum | What to do if the task is already running (see below). |
depends_on |
string[] | Task names that must complete successfully before this task runs. |
depends_order |
"sequence" | "parallel" |
How the depends_on tasks are executed. sequence = one after another. |
save_buffers |
boolean | table | Save modified project buffers before the task (and its dependencies) run. |
if_running values:
| Value | Behaviour |
|---|---|
wait |
Wait for the running instance to finish successfully. |
restart |
Stop the current instance and start a new one. |
refuse |
Do not start a new instance if one is already running. |
parallel |
Start a new instance alongside any existing ones. |
save_buffers can be true (save every modified project buffer) or a table
with glob filters:
[tasks.build]
type = "shell"
command = "make"
save_buffers = { include = ["src/**"], exclude = ["**/*.tmp"], include_hidden = false }Hidden files (dotfiles / files under dot-directories) are skipped unless
include_hidden = true.
Any task value can contain {{ … }} holes that are evaluated when the task
runs. The interior of a hole is a small expression language: function calls,
comma-separated arguments, string literals, numbers, booleans, and ..
concatenation. Nesting is function composition — f(g(x)).
[tasks.run]
type = "process"
command = "{{ projectdir }}/build/app"
cwd = "{{ filedir }}"
env = { API_KEY = "{{ env('API_KEY') }}", REV = "{{ shell('git rev-parse --short HEAD') }}" }If the entire trimmed value is a single hole, the expression's native value
is preserved (a number stays a number, a boolean a boolean, and a nil result
drops the field). Otherwise the value is string interpolation.
| Expression | Result |
|---|---|
file (filetype?) |
Absolute path of the current file. |
filename (filetype?) |
File name with extension. |
fileroot (filetype?) |
Absolute path without the extension. |
filedir |
Absolute directory of the current file. |
fileext |
Extension (without the dot). |
cwd |
The task's cwd, or the editor cwd. |
projectdir |
Absolute path of the project root (where the tasks file lives). |
env(NAME) |
Value of an environment variable. |
shell(CMD) |
Stdout of a shell command, trailing newlines stripped. |
prompt(TEXT, default?, completion?) |
Ask for input at run time. |
lbrace |
A literal {{ (escape hatch; same as {{{{). |
Strings inside a hole use "…" or '…' and are always verbatim (no escape
sequences, no nested interpolation) — pick the quote your content lacks. To
build up a value, concatenate with ..:
command = "{{ shell('echo ' .. file()) }}"Define reusable named expressions under [expressions]. They may reference
built-ins, other inline macros, and their own positional arguments $1, $2, …
[expressions]
greet = "'Hello, ' .. $1 .. '!'"
tagged = "greet($1) .. ' [' .. env('USER') .. ']'"
outdir = "{{ projectdir }}/build/{{ $1 }}"
[tasks.run]
type = "shell"
command = "echo {{ tagged('world') }} && ls {{ outdir('release') }}"You can evaluate any expression against the current project without running a task:
:Tasks eval file
:Tasks eval {{ shell('git branch --show-current') }}See docs/expression-grammar.md for the full grammar.
Set quickfix_matcher on a process or shell task to parse its output into
the quickfix list as it streams. The list is cleared when the task starts and
populated line by line, so you can :copen and jump straight to errors.
Built-in matchers:
| Name | Tooling |
|---|---|
gcc |
GCC / Clang (incl. template “required from” chains) |
msvc |
MSVC (file(line): error CXXXX: …) |
tsc |
TypeScript compiler |
go |
Go compiler |
gotest |
go test output |
cargo |
Rust / Cargo (errors and panics) |
python |
Python tracebacks |
pytest |
pytest / unittest |
linter |
Generic file:line:col: CODE: msg (ESLint, Pylint, Flake8, Mypy, …) |
unix |
Generic file:line:col: message |
The user command (named Tasks by default) is the single entry point. Called
with no argument it opens the task picker.
| Invocation | Action |
|---|---|
:Tasks / :Tasks run |
Pick a task to run (with a live preview of its definition). |
:Tasks clear |
Dispose all finished task runs (and clean up ezdap, if present). |
:Tasks rerun |
Re-run the last task. |
:Tasks stop |
Pick a running task to stop. |
:Tasks cancel |
Stop all running tasks. |
:Tasks eval [expr] |
Evaluate an expression (or bare expression name) and echo it. |
:Tasks template |
Insert a task template at the cursor (only in the tasks file). |
:Tasks panel |
Toggle the output window. |
:Tasks panel jump N |
Focus tab N (requires dock.nvim). |
:Tasks panel remove |
Dispose a finished task run. |
Subcommands and task names complete on <Tab>.
:Tasks panel jump N takes the tab number as an argument, so you can bind it
with a count prefix — e.g. 3<leader>tj focuses tab 3:
vim.keymap.set("n", "<leader>tj", function()
vim.cmd("Tasks panel jump " .. vim.v.count1)
end, { desc = "Jump to task output tab [count]" })Every run gets its own scratch log buffer — a timestamped record of what it did
(dependencies waited on, the resolved task, files saved, how it ended) — named
after the run, e.g. tomltasks://build#1, alongside whatever buffers the task
type spawns (a terminal per process/shell task, streaming live).
Where those buffers appear depends on your setup:
- With dock.nvim — each run becomes a
numbered tab in the shared dock panel, carrying a status badge (
▶running,✓ok,✗failed,⧗waiting on dependencies) and one page per buffer. Click a tab or use:Tasks panel jump Nto switch; new output on an inactive tab is flagged with an unread marker.:Dock cleanasks each tab to shed itself: a finished run is disposed, buffers and all, and a running one keeps its tab. - Without it — a single bottom split shows the highest-priority buffer of the running task, swapping the occupant rather than stacking splits. A task's terminal outranks its log, so the log is what you see until there is real output.
:Tasks panel toggles the window; :Tasks panel remove disposes a finished run
(its buffers included), and :Tasks clear disposes every finished run at once.
Disposal always goes through the runner, whichever end it is asked from —
:Tasks, or :Dock clean on the tab. It owns the run, so it decides whether
the run may go and it deletes the buffers; the view only ever asks, and reacts
once it has happened.
Opening the tasks file gives you rich, schema-aware editing — including any task types, adapters, and expressions available in your setup:
- Completion — task types, field names, enum values, dependency task names,
and expression names/arguments inside
{{ … }}. - Diagnostics — schema validation, unknown fields, type errors, and malformed expressions, shown inline as you type.
- Hover — field and expression documentation.
- Code actions and formatting for the TOML document.
The tasks file gets its own tomltasks filetype, so your existing TOML tooling
is left untouched.
Call setup() (directly, or via your plugin manager's opts). All fields are
optional; defaults shown:
require("tomltasks").setup({
enabled = true, -- register the command and editing support
command = "Tasks", -- name of the user command
tasks_filename = "tasks.toml", -- per-project tasks file (also the project marker)
storage_dir = ".tomltasks", -- per-project state directory
})Toggle the plugin at runtime with require("tomltasks").enable() /
require("tomltasks").disable(), and check whether the cwd is an tomltasks
project with require("tomltasks").in_project().
Released under the MIT License. Debug support is provided by ezdap.nvim.