Skip to content

Metadata

Ben Garren edited this page Nov 15, 2025 · 13 revisions

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

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
}

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)
    require("checkmate").set_todo_state(todo, "checked")
  end,
  on_remove = function(todo)
    require("checkmate").set_todo_state(todo, "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

As of v0.12, Checkmate offers two options for picker-based selection and update of a metadata's value:

  1. Use the plugin's own picker implementation populated from the metadata's choices table or function return value. (default)
    • The key here is that the selection options are derived from the metadata's choices field (defined in the config).
  2. Call an arbitrary custom_picker function to generate the potential values and pass the selection back to Checkmate to update the metadata
    • Here the selection options are derived from custom code at runtime, allowing external plugins to generate the possible values.

Both options are accessible via the public API:

require("checkmate").select_metadata_value(opts)

By default, the above code will use the values from the metadata's choices field (defined in your config) and the plugin's picker (defined in ui.picker).

In contrast, the below code passes a custom_picker opt and calls the complete callback with the value that will be used to update the metadata.

require("checkmate").select_metadata_value({
  custom_picker = function(context, complete)
    -- Call some code that generates potential values...
    -- Then make your selection:
    complete(value)
  end
})

See below for some additional explanation and examples.

Which to choose?

  • If potential values for your metadata are relatively simple to obtain, i.e., you can list them in a table or write your own choices function, then using the plugin's picker is probaby simplest. The picker UI can still be customized to use an external plugin, such as snacks, mini.pick, fzf-lua, etc.

  • If you'd like to generate potential values from a more complex implementation, such as using an external, specialized plugin (e.g., a dedicated fuzzy finder), then you should use the custom_picker function and use the provided callback to update the metadata.

Using choices (option 1)

First, ensure that your metadata definition (in your config) defines a choices field (either a list or a function that returns a list). I.e., string[]|checkmate.ChoicesFn. See Recipes below for some examples.

Then, configure the picker, if you desire:

checkmate.nvim provides default implementations for the following pickers:

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

-- `ui.picker` examples

-- 1. Auto-detect (default)
-- Tries telescope → snacks → mini → native vim.ui.select
opts = {
  ui = {
    picker = nil, -- or simply omit the key
  }
}

-- 2. Force specific picker
opts = {
  ui = {
    picker = "snacks", -- or "telescope", "mini"
  }
}

-- 3. Use native vim.ui.select
-- For external plugins that register on the vim.ui.select,
-- this is also how you would use that picker
opts = {
  ui = {
    picker = "select",
  }
}

Register with ui select

Some picker plugins can utilize the native vim.ui.select.

fzf-lua:

require("fzf-lua").register_ui_select()

Checkmate config:

opts = {
  ui = {
    picker = "select"
  }
}

Using custom_picker (option 2)

The custom_picker function has the following signature: fun(context: checkmate.MetadataContext, complete: fun(value: string?)). See Metadata Context.

The below example shows how to use a snacks.nvim files picker to update a metadata value.

require("checkmate").select_metadata_value({
  custom_picker = function(ctx, complete)
    require("snacks").picker.files({
      confirm = function(picker, item)
        if item then
          vim.schedule(function()
            complete(item.text)
          end)
        end
        picker:close()
      end,
    })
  end,
})

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 (not guaranteed to be stable).

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,
}