Automatic Snippets with Snippy
Introduction
There are two approaches here:
- 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.
- Snippets that are automatic based on some environment condition
- 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
endYou 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 objectMy 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 -- trueAnd 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
endInitialize 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
An Alternative Approach — Symlinks
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
Using Symlinks for automatic snippets
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 ]]
endUsing 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 MFootnotes
-
https://neovim.io/doc/user/treesitter.html#TSNode%3Achild_containing_descendant() ↩
-
https://github.com/David-Kunz/treesitter-unit/blob/main/lua/treesitter-unit/init.lua#L20 ↩
-
https://old.reddit.com/r/neovim/comments/10s3nmn/math_zone_detection_for_luasnip/ ↩
-
https://github.com/frankroeder/dotfiles/blob/657a5dc559e9ff526facc2e74f9cc07a1875cac6/nvim/lua/tsutils.lua#L59 ↩
-
https://github.com/nvim-treesitter/nvim-treesitter/issues/1184#issuecomment-830388856 ↩