Skip to content

Metadata

bngarren edited this page Jul 6, 2025 · 10 revisions

The metadata system adds simple @tag(value) items to todos while exposing a robust interface for user configuration.

⚠️ The following information applies to release 0.9+

Custom metadata (basic)

To define custom metadata, simply add a new field to config.metadata table:

-- adds a @due tag
opts = {
  metadata = {
    due = {
      -- Customize the style and functionality here
    },
  },
}

This won't accomplish much, yet, other than checkmate now being able to recognize and parse a @due() tag.

Let's give it some basic functionality. First, how can we toggle the insertion/removal of this tag?

Toggle behavior

  1. Provide a keymap for this specific metadata that will add/remove it using the key option
  2. Use commands or public API to manage it programatically

Use the key option:

-- opts.metadata
due = {
  key = "<leader>Td"
},

Or, manage the metadata programatically:

require("checkmate").add_metadata("due")
require("checkmate").remove_metadata("due")
require("checkmate").toggle_metadata("due")

or, via user commands: :Checkmate metadata add due, :Checkmate metadata toggle due, etc.

Next, let's define a default value for when this tag is added to the todo.

Default value

The 'default value' is the value inserted into the metadata tag when it is added to the todo item.

Use the get_value option, passing a string or a function that returns a string.

For a metadata that deals with static values, can simply use a default string:

-- opts.metadata
priority = {
  get_value = "medium"
}

If you want a calculated default value:

-- opts.metadata
due = {
  get_value = function()
    -- tomorrow's date (a very naive implementation)
    local t = os.date("*t")
    t.day = t.day + 1
    local tomorrow = os.time(t)
    return os.date("%m/%d/%y", tomorrow)
  end
}

Note, the get_value function also accepts a context parameter that can provide relevant information about the todo item along with helper functions.

This could allow you to to calculate an @elapsed time from a @started and @done tag:

-- opts.metadata
-- assuming you have a 'started' and 'done' (which are defaults)
elapsed = {
  get_value = function(context)
  local _, started_value = context.todo.get_metadata("started")
  local _, done_value = context.todo.get_metadata("done")

  -- Ensure your @started and @done tags store a consistent strptime format
  if started_value and done_value then
    local started_ts = vim.fn.strptime("%m/%d/%y %H:%M", started_value)
    local done_ts = vim.fn.strptime("%m/%d/%y %H:%M", done_value)
    -- convert to days
    return string.format("%.1f d", (done_ts - started_ts) / 86400)
  end
  return ""
}

Style

A metadata's style can be configured as a table or by passing a function that returns a table, which must be a highlight definition, see vim.api.keyset.highlight.

Style the @done tag green:

-- opts.metadata
done = {
  style = { fg = "#96de7a" } -- green
}

Style the @priority tag based on its current value:

done = {
  style = function(context)
  local value = context.value:lower()
  if value == "high" then
    return { fg = "#ff5555", bold = true }
  elseif value == "medium" then
    return { fg = "#ffb86c" }
  elseif value == "low" then
    return { fg = "#8be9fd" }
  else -- fallback
    return { fg = "#8be9fd" }
  end
end,
}

The context parameter provides info about the todo item, metadata, and exposes some helpers.

Custom metadata (advanced)

Completion and choices

The metadata system supports completion for values, allowing you to define a set of choices that you can select from. This is particularly useful for tags with predefined options.

Define a simple array of choices:

-- opts.metadata
status = {
  choices = { "backlog", "in-progress", "blocked", "review", "done" },
  key = "<leader>Ts",
}

You can then use :Checkmate metadata select_value cmd (or use keymap) to open a picker UI and select from these values.

Dynamic choices

You can also use a function to generate choices dynamically, supporting both sync and async functions.

Return the choices synchronously:

-- opts.metadata
assignee = {
  choices = function(context)
    -- get git contributors
    local contributors = vim.fn.systemlist("git log --format='%an' | sort -u")
    return contributors
  end,
}

The context parameter provides info about the todo item, metadata, and exposes some helpers.

Or, even more powerfully, use an async pattern for fetching choices 🤘

Fetch git remote branches (your implementation may vary):

-- opts.metadata
branch = {
  key = "<leader>Tb",
  choices = function(context, callback)
    vim.system(
      { "git", "branch", "-r", "--format=%(refname:short)" },
      { text = true },
      vim.schedule_wrap(function(res)
        local items = {}
        if res.code == 0 then
          items = vim.tbl_filter(function(b)
            return not b:match("^origin/HEAD")
          end, vim.split(res.stdout, "\n", { trimempty = true }))
          items = vim.tbl_map(function(b)
            return b:gsub("^origin/", "")
          end, items)
        end
        callback(items)
      end)
    )
 end
}

Note, that we pass the results through the callback function, though this may not be necessary in all cases (such as this one) in which the :wait() runs synchronously.

Lifecycle callbacks

React to metadata changes with on_add, on_remove, and on_change callbacks. This enables workflows where metadata can trigger todo state changes or other actions.

  • on_add: triggered when metadata is first added to a todo
  • on_change: triggered when an existing metadata's value is changed (does not fire on initial add or removal)
  • on_remove: triggered when metadata is removed from a todo

⚠️ The lifecycle hooks currently only fire for programmatic changes, i.e. metadata changed by the API—not by the user modifying the buffer directly.


Auto-check on adding @done:

-- opts.metadata
done = {
  get_value = function()
    return os.date("%m/%d/%y %H:%M")
  end,
  on_add = function(todo_item)
    require("checkmate").set_todo_item(todo_item, "checked")
  end,
  on_remove = function(todo_item)
    require("checkmate").set_todo_item(todo_item, "unchecked")
  end,
}

Cursor and selection behavior

Control where the cursor moves after inserting metadata with jump_to_on_insert and select_on_insert.

-- opts.metadata
estimate = {
  get_value = "0h",
  jump_to_on_insert = "value",  -- Jump cursor to the value
  select_on_insert = true,       -- Select the value for easy replacement
}

This is particularly useful for tags where you always want to immediately edit the default value.

Navigation

When the cursor is within a todo, you can quickly cycle through each metadata with:

  • Forward: :Checkmate metadata jump_next or require("checkmate").jump_next_metadata
  • Backward: :Checkmate metadata jump_previous or require("checkmate").jump_previous_metadata

Sorting and Display Order

Control the order in which metadata appears using sort_order:

-- opts.metadata
priority = {
  sort_order = 10,  -- Appears first
},
status = {
  sort_order = 20,  -- Appears second
},
due = {
  sort_order = 30,  -- Appears third
}

Metadata pickers

checkmate.nvim provides an implementation for the following pickers:

A preferred picker can be designated in the plugin opts ui.picker.

Custom picker

You can use your own picker implementation by passing a function for ui.picker:

snacks.nvim:

opts = {
  ui = {
    picker = function(items, opts)
      ---@type snacks.picker.ui_select
      require("snacks").picker.select(items, {
        -- ... plugin-specific opts
      }, function(item)
        opts.on_choice(item) -- << IMPORTANT!
      end)
    end
  }
}

fzf-lua:

opts = {
  ui = {
    picker = function(items, opts)
      require("fzf-lua").fzf_exec(items, {
        actions = {
          ["default"] = function(selected)
            opts.on_choice(selected[1])
          end,
        },
        winopts = {
          on_close = function()
            opts.on_choice(nil)
          end,
        },
      })
    end,
  },
}

Just make sure you call the opts.on_choice callback with the selected choice.

With fzf-lua, can also register it as the UI for vim.ui.select with require("fzf-lua").register_ui_select()

Metadata Context

The following fields are available in the checkmate.MetadataContext table:

  • name - tag name
  • value - current value
  • todo - associated todo
  • buffer - buffer number

The todo table (see checkmate.Todo) provides access to some exposed todo item data and helper functions, as well as the entire internal representation via _todo_item for advanced use cases.

Best Practices

  • Consistent date formats: When using dates in metadata, stick to a consistent format across all tags for easier parsing and comparison.

Recipes

Context-aware metadata

Create a metadata tag/value based on the todo's text (first line).

E.g., if "bug" string is in the todo text, use @type(bug) with associated styling when @type is inserted

-- opts.metadata
type = {
  get_value = function(context)
    local text = context.todo.text:lower()
    
    if text:match("bug") or text:match("fix") then
      return "bug"
    elseif text:match("feature") or text:match("implement") then
      return "feature"
    elseif text:match("refactor") then
      return "refactor"
    elseif text:match("doc") then
      return "documentation"
    else
      return "task"
    end
  end,
  choices = { "bug", "feature", "refactor", "documentation", "task", "chore" },
  style = function(context)
    local colors = {
      bug = { fg = "#ff5555", bold = true },
      feature = { fg = "#50fa7b" },
      refactor = { fg = "#ff79c6" },
      documentation = { fg = "#f1fa8c" },
      task = { fg = "#8be9fd" },
      chore = { fg = "#6272a4" }
    }
    return colors[context.value] or { fg = "#f8f8f2" }
  end,
}

Git-based metadata

Get open issues

--- opts.metadata
issue = {
  choices = function(context, callback)
    vim.system({
      "curl",
      "-sS",
      "https://api.github.com/repos/bngarren/checkmate.nvim/issues?state=open",
    }, { text = true }, function(out)
      if out.code ~= 0 then
        callback({})
        return
      end

      local ok, issues = pcall(vim.json.decode, out.stdout)
      if not ok or type(issues) ~= "table" then
        callback({})
        return
      end

      local result = vim.tbl_map(function(issue)
        return string.format("#%d %s", issue.number, issue.title)
      end, issues)

      callback(result)
    end)
  end,
}

Since we use vim.system()'s callback parameter here, we must return the items via the choices function's callback rather than directly.

Choose a project file

A basic example of how you can choose a filename from your current project. This is a coarse implementation of async behavior using vim.defer_fn to yield back to the event loop.

-- opts.metadata
file = {
  key = "<leader>Tmf",
  choices = function(_, cb)
    local function scan_dir_async(path, items, on_done)
      local ignore = { ".git" }
      local it = vim.uv.fs_scandir(path)
      if not it then
        return on_done()
      end
      local function step()
        local name, t = vim.uv.fs_scandir_next(it)
        if not name then
          return on_done()
        end
        local full = path .. "/" .. name
        if t == "file" then
          local short = vim.fn.fnamemodify(full, ":.")
          table.insert(items, short)
        elseif t == "directory" and not vim.tbl_contains(ignore, name) then
          scan_dir_async(full, items, function()
            vim.defer_fn(step, 0)
          end)
          return
        end
        vim.defer_fn(step, 0)
      end
      step()
    end

    local project_root = vim.fs.root(0, ".git") or vim.loop.cwd()
    local items = {}
    scan_dir_async(project_root, items, function()
      cb(items)
    end)
  end,
}