Automatic Snippets with Snippy

🧭

Introduction

There are two approaches here:

  1. Conditional Snippets
    • Snippets that are automatic based on some environment condition
      • Initially a workaround for the poor performance of VimTeX highlighting that would otherwise provide auto zones for snippets. I grew to love it even though treesitter is now an option.
  2. Contextual Snippets
    • Snippets that are automatic based on the context of the cursor using Treesitter

Conditional Snippets

Motivating Example

In the setup for snippy, include the following:

  require('snippy').setup({
    enable_auto = true,
    expand_options = {
      -- Here we can create optional environments for things
      -- e.g. modal snippets based on an env var:
      l = function()
        return My_globals.get("snippets_mode") == "latex"
      end
      -- Snippets dependent on a treesitter environment etc.
    }
  })

This function allows us to write conditional snippets based on the environment, consider the following:

snippet test "Testing things" lA
  Today is `!lua os.date('%Y-%m-%d')`

The l tells the snippet only to advance if the function before evaluates to true, so if My_globals.get("snippets_mode") == "latex" is true, then the snippet will expand.

This can be bound to a function itself, the below shows how to set a global environment variable and then use it to toggle the snippets, in this example lua My_globals.toggle_state("latex") will toggle the latex snippets on and off.

 
-- Create the table with setters and getters
My_globals = {}
function My_globals.set(key, value)
  My_globals[key] = value
end
 
function My_globals.get(key)
  return My_globals[key]
end
 
-- Define the available states
My_globals.states = {
  normal = "normal",
  latex = "latex",
}
 
function My_globals.get_state()
  return My_globals.state
end
 
--- Usage
---
---     My_globals.set_state(My_globals.states.normal)
function My_globals.set_state(new_state)
  My_globals.state = My_globals.states[new_state]
end
 
function My_globals.reset_state()
  My_globals.set_state(My_globals.states.normal)
end
 
-- Set the state to normal
My_globals.set_state("normal")
 
--- Toggle the given state from normal
---@param target_state string: the state to toggle to
function My_globals.toggle_state(target_state)
  if target_state == nil then
    -- Just revert to normal
    My_globals.reset_state()
    print("Reverting to normal Mode")
  elseif target_state == My_globals.states.normal then
    print("Toggling normal is a no-op")
  else
    if My_globals.get_state() == target_state then
      -- Revert to normal
      My_globals.reset_state()
    else
      My_globals.set_state(target_state)
    end
  end
end
 

Full Example

Create a Class for State

Summary

Create a class to manage the state of things, this class will be used to create a global object. For example:

  • Initialize the Object
      Snippy_state = require('utils/env_states').new("python", "latex")
  • Set the state
    Snippy_state.set_state(Snippy_state.states.latex)
  • Test the State Using boolean attributes means they can be auto-completed from the ex command line: :lua Snippy_state.is_state.<TAB>
    Snippy_state.is_state.latex
Code
A Simple Example

This example does not expose the booleans as attributes, but it motivates the idea

ObjectState = {}
ObjectState.__index = ObjectState
 
function ObjectState.new(states)
    local self = setmetatable({}, ObjectState)
    self.states = states or { "on", "off", "broken" } -- default to these if no states provided
    self.currentState = nil
    return self
end
 
function ObjectState:set(state)
    for _, v in ipairs(self.states) do
        if state == v then
            self.currentState = state
            return true
        end
    end
    print("Invalid state") -- Or handle the error in a different way that makes sense for your application
    return false
end
 
function ObjectState:get()
    return self.currentState
end

You can use this class like so:

local obj = ObjectState.new({ "on", "off" }) -- create an object with only two states
obj:set("on")  -- set the state to on
print(obj:get()) -- prints "on"
obj:set("broken") -- prints "Invalid state", because "broken" is not a valid state for this object
My Implementation

This is the implementation that I went with in ~/.config/nvim/lua/utils/env_states.lua, it can be used like so:

Snippy_state = State.new({ "latex", "python" })
Snippy_state.is_state.a -- false
Snippy_state:toggle(my_state.allowed_states.a)
Snippy_state.is_state.a -- true

And is implemented thusly:

local M = {} -- define a table to hold our module
 
State = {}
State.__index = State
 
--------------------------------------------------------------------------------
-- Constructor -----------------------------------------------------------------
--------------------------------------------------------------------------------
 
function State.new(states)
  local self = setmetatable({}, State) ---@class State
  self.state = nil
  self.allowed_states = {}
  -- use keys so they're exposed by autocomplete
  self.allowed_states = {}
  for _, s in pairs(states) do
    self.allowed_states[s] = s
  end
  -- Define a table of booleans so exposed by autocomplete
  self.is_state = {}
  self:update()
  return self
end
 
--------------------------------------------------------------------------------
-- Methods ---------------------------------------------------------------------
--------------------------------------------------------------------------------
 
-- Comparison
function State:check_state(state_to_check)
  if self.state == nil then
    return false
  else
    return self.state == state_to_check
  end
end
 
-- Update boolean status of states
function State:update()
  for _, s in pairs(self.allowed_states) do
    self.is_state[s] = self:check_state(s)
  end
end
 
-- Check before set
function State:state_is_allowed(target_state)
  -- nil value is permitted, it means default
  if target_state == nil then
    return true
  end
 
  local allowed_string = ""
  for k, v in pairs(self.allowed_states) do
    allowed_string = allowed_string .. "\n- " .. v
    if v == target_state then
      return true
    end
  end
  print("Warning: " .. target_state .. " is not an allowed state, must be one of:")
  print(allowed_string)
  return false
end
 
-- Setter
function State:set(target_state)
  if self:state_is_allowed(target_state) then
    self.state = target_state
    self:update()
  else
    print("Not allowed")
  end
end
 
-- Toggle State
function State:toggle(target_state)
  if self.state == nil then
    self:set(target_state)
  else
    self:set(nil)
  end
end

Initialize that State in the Snippy Setup

Explanation

When configuring snippy, you can initialize the state object and then use it to determine which snippets to expand.

It’s important to wrap that in pcall to avoid errors if the file is not found, print can be used to give a warning which will appear in :messages

Code

Here is what my Snippy setup looks like:

use { 'dcampos/nvim-snippy', config = function()
  local snippy_state_loaded = false
  local package = 'zutils/env_states'
  if pcall(require, package_name) then
    snippy_state_loaded = true
    Snippy_state = require(package).Snippy_state()
  else
    print("WARNING: could not load " .. package .. ".lua")
  end
  require('snippy').setup({
    enable_auto = true,
    expand_options = {
      -- Here we can create optional environments for things
      -- e.g. modal snippets based on an env var:
      l = function()
        -- return My_Snippy_env.get_state() == "latex"
        return Snippy_state.is_state.latex and snippy_state_loaded
      end
      -- You may include more, e.g. snippets based on treesitter (explained Later)
    }
  })
end }

Create a Binding to Toggle the State

Explanation

Bind a key to toggle the state of the object, this can be used to switch between different snippets. I’ve configured this in which key

Code
local wk = require("which-key")
 
wk.add({
  { "<leader>t",  group = "toggle" },
  { "<leader>ts", group = "Snippets Mode" },
  {
    "<leader>tsl",
    function()
      Snippy_state:toggle("latex")
    end,
    desc = "LaTeX Mode"
  }
})

In ordinarly lua:

vim.api.nvim_set_keymap(
    'n',
    '<leader>tsl',
    '',
    {
        noremap = true,
        silent = true,
        callback = function Snippy_state:toggle("latex") end
    })
 

Create a Snippet that Expands Conditionally

Explanation

Now using this condition, one can create a snippet that only expands after toggling them with that keybinding.

Code
snippet ci "Integral" lA
	$$
	\int_{a}^{b} f(x) \, dx
	$$
	$0

I was curious if this approach lead to any performance issues, so I created a function that toggles the snippets by swapping the symlink target of the base snippet file between the normal and auto snippet files. I didn’t notice any performance differences so I elected to keep the self-contained lua implementation above.

The code is included below in Appendix.

Contextual with Treesitter

Introduction

Expanding snippets based on the context of the cursor is extremely useful for , if I have a different snippets for a math environment… it’s much quicker to write math!

Implementation

Setting Mathzone

Historically, castelDev set the mathzone to be the math environment using vimtex:

vimtex#syntax#in_mathzone
    expand_options = {
        m = function()
            return vim.fn["vimtex#syntax#in_mathzone"]() == 1
        end,
        c = function()
            return vim.fn["vimtex#syntax#in_comment"]() == 1
        end,
    }
 

It’s more ideal to use treesitter, which is now built into Neovim. Looking through the documentation 1 was daunting so I looked at an alternative implementation 2 which looked promising, but then I found this reddit post 3 which linked to someone elses implementation 4 which in turn cited an implementation from 5 from the treesitter repo.

To get an idea for this run :InspectTree and you’ll see the s-expression structure of the document:

(displayed_equation ; [374, 0] - [376, 2]
              (text ; [375, 0] - [375, 3]
                word: (word) ; [375, 0] - [375, 1]
                word: (operator) ; [375, 1] - [375, 2]
                word: (word)))

We can query this document to see if the cursor is in a math environment. This could even be extended to dynamically for other regions. It seems the tree does not include the language of code blocks, but for that FeMaco is a good option.

The lua code is included in the Appendix, to use it add it to your ~/.config/nvim/lua directory and then try it out in the ex command (i.e. `:lua print(“test”)):

print(require('utils/tsutils_math.lua').in_mathzone())
nmap <F1> :lua print(require('utils/tsutils_math.lua').in_mathzone())<CR>

You’ll notice on this very document, this location has a false positive, but it’s pretty good.

Then it’s simply a matter of adding that to the snippy setup:

require('snippy').setup({
  enable_auto = true,
  expand_options = {
    -- Here we can create optional environments for things
    -- e.g. modal snippets based on an env var:
    l = function()
      return My_globals.get("snippets_mode") == "latex"
    end,
    -- Snippets dependent on a treesitter environment etc.
    m = function()
      return require('utils/tsutils_math').in_mathzone()
    end,
    c = function()
      return require('utils/tsutils_math').in_comment()
    end,
  }
})

Giving that function to Snippy

Appendix

1

-- A function to toggle between normal and auto snippets for LaTeX
-- This is achieved by swapping the symlink target of the base snippet file
-- between the normal and auto snippet files.
-- This is a workaround for the poor performance of VimTeX highlighting that
-- would otherwise provide auto zones for snippets.
 
-- Toggle snippets
local vimfn = vim.fn
local print = print
 
local function get_home_path(path)
  return vimfn.expand("$HOME") .. path
end
 
local tex_snippet_paths = {
  base = get_home_path("/.config/nvim/snippets/tex.snippets"),
  normal = get_home_path("/.config/nvim/snippets/tex_normal"),
  auto = get_home_path("/.config/nvim/snippets/tex_auto"),
}
 
local md_snippet_paths = {
  base = get_home_path("/.config/nvim/snippets/markdown.snippets"),
  normal = get_home_path("/.config/nvim/snippets/markdown_normal"),
  auto = get_home_path("/.config/nvim/snippets/tex_auto"),
}
 
local function debug_print(condition, message)
  if condition then
    print(message)
  end
end
 
local function get_symlink_target(symlink_path)
  local symlink_target = vimfn.resolve(symlink_path)
  debug_print(symlink_target ~= "", "Symlink target of " .. symlink_path .. " is " .. symlink_target)
  return symlink_target
end
 
local function is_known_symlink_target(symlink_target, known_targets)
  local is_known_target = false
  for _, target in ipairs(known_targets) do
    if symlink_target == target then
      is_known_target = true
      break
    end
  end
  debug_print(is_known_target, "Symlink target matches with a known target.")
  return is_known_target
end
 
local function swap_symlink(symlink_path, symlink_target, targets)
  local current_target_index = (symlink_target == targets[1]) and 2 or 1
  local success, errmsg = os.remove(symlink_path)
  if success then
    success, errmsg = os.execute(string.format('ln -s %s %s', targets[current_target_index], symlink_path))
    debug_print(success, "Successfully swapped symlink target to " .. targets[current_target_index])
  end
  if not success then
    print("Failed to swap symlink target. Reason: " .. errmsg)
  end
end
 
function rename_markdown_auto_snippets()
  -- Toggle markdown_auto.snippets by removing the extension
  local auto_file_name_disabled = "markdown_auto"
  local auto_file_name_enabled = "markdown_auto.snippets"
 
  -- Get the foll path
  local auto_file_name_enabled = get_home_path("/.config/nvim/snippets/" .. auto_file_name_enabled)
  local auto_file_name_disabled = get_home_path("/.config/nvim/snippets/" .. auto_file_name_disabled)
 
  -- Check if the snippets file exists
  local auto_snippet_exists = vimfn.filereadable(auto_file_name_enabled) == 1
 
  -- If so rename it
  if auto_snippet_exists then
    os.rename(auto_file_name_enabled, auto_file_name_disabled)
    print("Moved " .. auto_file_name_enabled .. " to " .. auto_file_name_disabled)
  else
    -- If not rename it back
    os.rename(auto_file_name_disabled, auto_file_name_enabled)
    print("Moved " .. auto_file_name_disabled .. " to " .. auto_file_name_enabled)
  end
end
 
function Snippy_Toggle_Auto()
  for _, snippet_paths in ipairs({ md_snippet_paths, tex_snippet_paths }) do
    local symlink_target = get_symlink_target(snippet_paths.base)
    if is_known_symlink_target(symlink_target, { snippet_paths.normal, snippet_paths.auto }) then
      swap_symlink(snippet_paths.base, symlink_target, { snippet_paths.normal, snippet_paths.auto })
    end
    rename_markdown_auto_snippets()
  end
  vim.cmd [[ :SnippyReload ]]
end

Using Treesitter to determine the context of the cursor

2

-- https://github.com/nvim-treesitter/nvim-treesitter/issues/1184#issuecomment-830388856
local has_treesitter, ts = pcall(require, 'vim.treesitter')
local _, query = pcall(require, 'vim.treesitter.query')
 
local M = {}
 
local MATH_ENVIRONMENTS = {
  displaymath = true,
  eqnarray = true,
  equation = true,
  math = true,
  array = true,
}
local MATH_NODES = {
  displayed_equation = true,
  inline_formula = true,
}
 
local function get_node_at_cursor()
  local cursor = vim.api.nvim_win_get_cursor(0)
  local cursor_range = { cursor[1] - 1, cursor[2] }
  local buf = vim.api.nvim_get_current_buf()
  local ok, parser = pcall(ts.get_parser, buf, 'latex')
  if not ok or not parser then return end
  local root_tree = parser:parse()[1]
  local root = root_tree and root_tree:root()
 
  if not root then return end
 
  return root:named_descendant_for_range(cursor_range[1], cursor_range[2], cursor_range[1], cursor_range[2])
end
 
function M.in_comment()
  if has_treesitter then
    local node = get_node_at_cursor()
    while node do
      if node:type() == 'comment' then
        return true
      end
      node = node:parent()
    end
    return false
  end
end
 
function M.in_mathzone()
  if has_treesitter then
    local buf = vim.api.nvim_get_current_buf()
    local node = get_node_at_cursor()
    while node do
      if MATH_NODES[node:type()] then
        return true
      end
      if node:type() == 'environment' then
        local begin = node:child(0)
        local names = begin and begin:field('name')
 
        if names and names[1] and MATH_ENVIRONMENTS[query.get_node_text(names[1], buf):gsub('[%s*]', '')] then
          return true
        end
      end
      node = node:parent()
    end
    return false
  end
end
 
return M

Footnotes

  1. https://neovim.io/doc/user/treesitter.html#TSNode%3Achild_containing_descendant()

  2. https://github.com/David-Kunz/treesitter-unit/blob/main/lua/treesitter-unit/init.lua#L20

  3. https://old.reddit.com/r/neovim/comments/10s3nmn/math_zone_detection_for_luasnip/

  4. https://github.com/frankroeder/dotfiles/blob/657a5dc559e9ff526facc2e74f9cc07a1875cac6/nvim/lua/tsutils.lua#L59

  5. https://github.com/nvim-treesitter/nvim-treesitter/issues/1184#issuecomment-830388856