neovim

Neovim text editor
git clone https://git.dasho.dev/neovim.git
Log | Files | Refs | README

snippet.lua (22176B)


      1 local G = vim.lsp._snippet_grammar
      2 local snippet_group = vim.api.nvim_create_augroup('nvim.snippet', {})
      3 local snippet_ns = vim.api.nvim_create_namespace('nvim.snippet')
      4 local hl_group = 'SnippetTabstop'
      5 local hl_group_active = 'SnippetTabstopActive'
      6 
      7 --- Returns the 0-based cursor position.
      8 ---
      9 --- @return integer, integer
     10 local function cursor_pos()
     11  local cursor = vim.api.nvim_win_get_cursor(0)
     12  return cursor[1] - 1, cursor[2]
     13 end
     14 
     15 --- Resolves variables (like `$name` or `${name:default}`) as follows:
     16 --- - When a variable is unknown (i.e.: its name is not recognized in any of the cases below), return `nil`.
     17 --- - When a variable isn't set, return its default (if any) or an empty string.
     18 ---
     19 --- Note that in some cases, the default is ignored since it's not clear how to distinguish an empty
     20 --- value from an unset value (e.g.: `TM_CURRENT_LINE`).
     21 ---
     22 --- @param var string
     23 --- @param default string
     24 --- @return string?
     25 local function resolve_variable(var, default)
     26  --- @param str string
     27  --- @return string
     28  local function expand_or_default(str)
     29    local expansion = vim.fn.expand(str) --[[@as string]]
     30    return expansion == '' and default or expansion
     31  end
     32 
     33  if var == 'TM_SELECTED_TEXT' then
     34    -- Snippets are expanded in insert mode only, so there's no selection.
     35    return default
     36  elseif var == 'TM_CURRENT_LINE' then
     37    return vim.api.nvim_get_current_line()
     38  elseif var == 'TM_CURRENT_WORD' then
     39    return expand_or_default('<cword>')
     40  elseif var == 'TM_LINE_INDEX' then
     41    return tostring(vim.fn.line('.') - 1)
     42  elseif var == 'TM_LINE_NUMBER' then
     43    return tostring(vim.fn.line('.'))
     44  elseif var == 'TM_FILENAME' then
     45    return expand_or_default('%:t')
     46  elseif var == 'TM_FILENAME_BASE' then
     47    return expand_or_default('%:t:r')
     48  elseif var == 'TM_DIRECTORY' then
     49    return expand_or_default('%:p:h:t')
     50  elseif var == 'TM_FILEPATH' then
     51    return expand_or_default('%:p')
     52  end
     53 
     54  -- Unknown variable.
     55  return nil
     56 end
     57 
     58 --- Transforms the given text into an array of lines (so no line contains `\n`).
     59 ---
     60 --- @param text string|string[]
     61 --- @return string[]
     62 local function text_to_lines(text)
     63  text = type(text) == 'string' and { text } or text
     64  --- @cast text string[]
     65  return vim.split(table.concat(text), '\n', { plain = true })
     66 end
     67 
     68 --- Computes the 0-based position of a tabstop located at the end of `snippet` and spanning
     69 --- `placeholder` (if given).
     70 ---
     71 --- @param snippet string[]
     72 --- @param placeholder string?
     73 --- @return Range4
     74 local function compute_tabstop_range(snippet, placeholder)
     75  local cursor_row, cursor_col = cursor_pos()
     76  local snippet_text = text_to_lines(snippet)
     77  local placeholder_text = text_to_lines(placeholder or '')
     78  local start_row = cursor_row + #snippet_text - 1
     79  local start_col = #(snippet_text[#snippet_text] or '')
     80 
     81  -- Add the cursor's column offset to the first line.
     82  if start_row == cursor_row then
     83    start_col = start_col + cursor_col
     84  end
     85 
     86  local end_row = start_row + #placeholder_text - 1
     87  local end_col = (start_row == end_row and start_col or 0)
     88    + #(placeholder_text[#placeholder_text] or '')
     89 
     90  return { start_row, start_col, end_row, end_col }
     91 end
     92 
     93 --- Returns the range spanned by the respective extmark.
     94 ---
     95 --- @param bufnr integer
     96 --- @param extmark_id integer
     97 --- @return Range4
     98 local function get_extmark_range(bufnr, extmark_id)
     99  local mark = vim.api.nvim_buf_get_extmark_by_id(bufnr, snippet_ns, extmark_id, { details = true })
    100 
    101  --- @diagnostic disable-next-line: undefined-field
    102  return { mark[1], mark[2], mark[3].end_row, mark[3].end_col }
    103 end
    104 
    105 --- @class (private) vim.snippet.Tabstop
    106 --- @field extmark_id integer
    107 --- @field bufnr integer
    108 --- @field index integer
    109 --- @field placement integer
    110 --- @field choices? string[]
    111 local Tabstop = {}
    112 
    113 --- Creates a new tabstop.
    114 ---
    115 --- @package
    116 --- @param index integer
    117 --- @param bufnr integer
    118 --- @param placement integer
    119 --- @param range Range4
    120 --- @param choices? string[]
    121 --- @return vim.snippet.Tabstop
    122 function Tabstop.new(index, bufnr, placement, range, choices)
    123  local extmark_id = vim.api.nvim_buf_set_extmark(bufnr, snippet_ns, range[1], range[2], {
    124    right_gravity = true,
    125    end_right_gravity = false,
    126    end_line = range[3],
    127    end_col = range[4],
    128    hl_group = index == 1 and hl_group_active or hl_group,
    129  })
    130 
    131  local self = setmetatable({
    132    extmark_id = extmark_id,
    133    bufnr = bufnr,
    134    index = index,
    135    placement = placement,
    136    choices = choices,
    137  }, { __index = Tabstop })
    138 
    139  return self
    140 end
    141 
    142 --- Returns the tabstop's range.
    143 ---
    144 --- @package
    145 --- @return Range4
    146 function Tabstop:get_range()
    147  return get_extmark_range(self.bufnr, self.extmark_id)
    148 end
    149 
    150 --- Returns the text spanned by the tabstop.
    151 ---
    152 --- @package
    153 --- @return string
    154 function Tabstop:get_text()
    155  local range = self:get_range()
    156  return table.concat(
    157    vim.api.nvim_buf_get_text(self.bufnr, range[1], range[2], range[3], range[4], {}),
    158    '\n'
    159  )
    160 end
    161 
    162 --- Sets the tabstop's text.
    163 ---
    164 --- @package
    165 --- @param text string
    166 function Tabstop:set_text(text)
    167  local range = self:get_range()
    168  vim.api.nvim_buf_set_text(self.bufnr, range[1], range[2], range[3], range[4], text_to_lines(text))
    169 end
    170 
    171 ---@alias (private) vim.snippet.TabStopGravity
    172 --- | "expand" Expand the current tabstop on text insert
    173 --- | "lock" The tabstop should NOT move on text insert
    174 --- | "shift" The tabstop should move on text insert (default)
    175 
    176 --- Sets the right gravity of the tabstop's extmark.
    177 --- Sets the active highlight group for current ("expand") tabstops
    178 ---
    179 ---@package
    180 ---@param target vim.snippet.TabStopGravity
    181 function Tabstop:set_gravity(target)
    182  local hl = hl_group
    183  local right_gravity = true
    184  local end_right_gravity = true
    185 
    186  if target == 'expand' then
    187    hl = hl_group_active
    188    right_gravity = false
    189    end_right_gravity = true
    190  elseif target == 'lock' then
    191    right_gravity = false
    192    end_right_gravity = false
    193  end
    194 
    195  local range = self:get_range()
    196  vim.api.nvim_buf_del_extmark(self.bufnr, snippet_ns, self.extmark_id)
    197  self.extmark_id = vim.api.nvim_buf_set_extmark(self.bufnr, snippet_ns, range[1], range[2], {
    198    right_gravity = right_gravity,
    199    end_right_gravity = end_right_gravity,
    200    end_line = range[3],
    201    end_col = range[4],
    202    hl_group = hl,
    203  })
    204 end
    205 
    206 --- @class (private) vim.snippet.Session
    207 --- @field bufnr integer
    208 --- @field extmark_id integer
    209 --- @field tabstops table<integer, vim.snippet.Tabstop[]>
    210 --- @field tabstop_placements integer[]
    211 --- @field current_tabstop vim.snippet.Tabstop
    212 --- @field tab_keymaps { i: table<string, any>?, s: table<string, any>? }
    213 --- @field shift_tab_keymaps { i: table<string, any>?, s: table<string, any>? }
    214 local Session = {}
    215 
    216 --- Creates a new snippet session in the current buffer.
    217 ---
    218 --- @package
    219 --- @param bufnr integer
    220 --- @param snippet_extmark integer
    221 --- @param tabstop_data table<integer, { placement: integer, range: Range4, choices?: string[] }[]>
    222 --- @return vim.snippet.Session
    223 function Session.new(bufnr, snippet_extmark, tabstop_data)
    224  local self = setmetatable({
    225    bufnr = bufnr,
    226    extmark_id = snippet_extmark,
    227    tabstops = {},
    228    tabstop_placements = {},
    229    current_tabstop = Tabstop.new(0, bufnr, 0, { 0, 0, 0, 0 }),
    230    tab_keymaps = { i = nil, s = nil },
    231    shift_tab_keymaps = { i = nil, s = nil },
    232  }, { __index = Session })
    233 
    234  -- Create the tabstops.
    235  for index, ranges in pairs(tabstop_data) do
    236    for _, data in ipairs(ranges) do
    237      self.tabstops[index] = self.tabstops[index] or {}
    238      table.insert(
    239        self.tabstops[index],
    240        Tabstop.new(index, self.bufnr, data.placement, data.range, data.choices)
    241      )
    242      table.insert(self.tabstop_placements, data.placement)
    243    end
    244  end
    245 
    246  return self
    247 end
    248 
    249 --- Returns the destination tabstop index when jumping in the given direction.
    250 ---
    251 --- @package
    252 --- @param direction vim.snippet.Direction
    253 --- @return integer?
    254 function Session:get_dest_index(direction)
    255  local tabstop_indexes = vim.tbl_keys(self.tabstops) --- @type integer[]
    256  table.sort(tabstop_indexes)
    257  for i, index in ipairs(tabstop_indexes) do
    258    if index == self.current_tabstop.index then
    259      local dest_index = tabstop_indexes[i + direction] --- @type integer?
    260      -- When jumping forwards, $0 is the last tabstop.
    261      if not dest_index and direction == 1 then
    262        dest_index = 0
    263      end
    264      -- When jumping backwards, make sure we don't think that $0 is the first tabstop.
    265      if dest_index == 0 and direction == -1 then
    266        dest_index = nil
    267      end
    268      return dest_index
    269    end
    270  end
    271 end
    272 
    273 --- Sets the right gravity for all the tabstops.
    274 ---
    275 --- @package
    276 function Session:set_gravity()
    277  local index = self.current_tabstop.index
    278  local all_tabstop_placements = self.tabstop_placements
    279  local dest_tabstop_placements = {}
    280 
    281  for _, tabstop in ipairs(self.tabstops[index]) do
    282    tabstop:set_gravity('expand')
    283    table.insert(dest_tabstop_placements, tabstop.placement)
    284  end
    285 
    286  for i, tabstops in pairs(self.tabstops) do
    287    if i ~= index then
    288      for _, tabstop in ipairs(tabstops) do
    289        local placement = tabstop.placement + 1
    290        -- Check if there other tabstops directly adjacent
    291        while
    292          vim.list_contains(all_tabstop_placements, placement)
    293          and not vim.list_contains(dest_tabstop_placements, placement)
    294        do
    295          placement = placement + 1
    296        end
    297 
    298        if vim.list_contains(dest_tabstop_placements, placement) then
    299          tabstop:set_gravity('lock')
    300        else
    301          tabstop:set_gravity('shift')
    302        end
    303      end
    304    end
    305  end
    306 end
    307 
    308 local M = { session = nil }
    309 
    310 --- Displays the choices for the given tabstop as completion items.
    311 ---
    312 --- @param tabstop vim.snippet.Tabstop
    313 local function display_choices(tabstop)
    314  assert(tabstop.choices, 'Tabstop has no choices')
    315 
    316  local text = tabstop:get_text()
    317  local found_text = false
    318 
    319  local start_col = tabstop:get_range()[2] + 1
    320  local matches = {} --- @type table[]
    321  for _, choice in ipairs(tabstop.choices) do
    322    if choice ~= text then
    323      matches[#matches + 1] = { word = choice }
    324    else
    325      found_text = true
    326    end
    327  end
    328 
    329  if found_text then
    330    table.insert(matches, 1, text)
    331  end
    332 
    333  vim.defer_fn(function()
    334    vim.fn.complete(start_col, matches)
    335  end, 100)
    336 end
    337 
    338 --- Select the given tabstop range.
    339 ---
    340 --- @param tabstop vim.snippet.Tabstop
    341 local function select_tabstop(tabstop)
    342  --- @param keys string
    343  local function feedkeys(keys)
    344    keys = vim.api.nvim_replace_termcodes(keys, true, false, true)
    345    vim.api.nvim_feedkeys(keys, 'n', true)
    346  end
    347 
    348  local range = tabstop:get_range()
    349  local mode = vim.fn.mode()
    350 
    351  if vim.fn.pumvisible() ~= 0 then
    352    -- Close the choice completion menu if open.
    353    vim.fn.complete(vim.fn.col('.'), {})
    354  end
    355 
    356  -- Move the cursor to the start of the tabstop.
    357  vim.api.nvim_win_set_cursor(0, { range[1] + 1, range[2] })
    358 
    359  -- For empty, choice and the final tabstops, start insert mode at the end of the range.
    360  if tabstop.choices or tabstop.index == 0 or (range[1] == range[3] and range[2] == range[4]) then
    361    if mode ~= 'i' then
    362      if mode == 's' then
    363        feedkeys('<Esc>')
    364      end
    365      vim.cmd.startinsert({ bang = range[4] >= #vim.api.nvim_get_current_line() })
    366    end
    367    if tabstop.choices then
    368      vim.fn.cursor(range[3] + 1, range[4] + 1)
    369      display_choices(tabstop)
    370    end
    371  else
    372    -- Else, select the tabstop's text.
    373    -- Need this exact order so cannot mix regular API calls with feedkeys, which
    374    -- are not executed immediately. Use <Cmd> to set the cursor position.
    375    local keys = {
    376      mode ~= 'n' and '<Esc>' or '',
    377      ('<Cmd>call cursor(%s,%s)<CR>'):format(range[1] + 1, range[2] + 1),
    378      'v',
    379      ('<Cmd>call cursor(%s,%s)<CR>'):format(range[3] + 1, range[4]),
    380      'o<c-g><c-r>_',
    381    }
    382    feedkeys(table.concat(keys))
    383  end
    384 end
    385 
    386 --- Sets up the necessary autocommands for snippet expansion.
    387 ---
    388 --- @param bufnr integer
    389 local function setup_autocmds(bufnr)
    390  vim.api.nvim_create_autocmd({ 'CursorMoved', 'CursorMovedI' }, {
    391    group = snippet_group,
    392    desc = 'Update snippet state when the cursor moves',
    393    buffer = bufnr,
    394    callback = function()
    395      -- Just update the tabstop in insert and select modes.
    396      if not vim.fn.mode():match('^[isS]') then
    397        return
    398      end
    399 
    400      local cursor_row, cursor_col = cursor_pos()
    401 
    402      -- The cursor left the snippet region.
    403      local snippet_range = get_extmark_range(bufnr, M._session.extmark_id)
    404      if
    405        cursor_row < snippet_range[1]
    406        or (cursor_row == snippet_range[1] and cursor_col < snippet_range[2])
    407        or cursor_row > snippet_range[3]
    408        or (cursor_row == snippet_range[3] and cursor_col > snippet_range[4])
    409      then
    410        M.stop()
    411        return true
    412      end
    413 
    414      for tabstop_index, tabstops in pairs(M._session.tabstops) do
    415        for _, tabstop in ipairs(tabstops) do
    416          local range = tabstop:get_range()
    417          if
    418            (cursor_row > range[1] or (cursor_row == range[1] and cursor_col >= range[2]))
    419            and (cursor_row < range[3] or (cursor_row == range[3] and cursor_col <= range[4]))
    420          then
    421            if tabstop_index ~= 0 then
    422              return
    423            end
    424          end
    425        end
    426      end
    427 
    428      -- The cursor is either not on a tabstop or we reached the end, so exit the session.
    429      M.stop()
    430      return true
    431    end,
    432  })
    433 
    434  vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI', 'TextChangedP' }, {
    435    group = snippet_group,
    436    desc = 'Update active tabstops when buffer text changes',
    437    buffer = bufnr,
    438    callback = function()
    439      -- Check that the snippet hasn't been deleted.
    440      local snippet_range = get_extmark_range(M._session.bufnr, M._session.extmark_id)
    441      if
    442        (snippet_range[1] == snippet_range[3] and snippet_range[2] == snippet_range[4])
    443        or snippet_range[3] + 1 > vim.fn.line('$')
    444      then
    445        M.stop()
    446      end
    447 
    448      if not M.active() then
    449        return true
    450      end
    451 
    452      -- Sync the tabstops in the current group.
    453      local current_tabstop = M._session.current_tabstop
    454      local current_text = current_tabstop:get_text()
    455      for _, tabstop in ipairs(M._session.tabstops[current_tabstop.index]) do
    456        if tabstop.extmark_id ~= current_tabstop.extmark_id then
    457          tabstop:set_text(current_text)
    458        end
    459      end
    460    end,
    461  })
    462 
    463  vim.api.nvim_create_autocmd('BufLeave', {
    464    group = snippet_group,
    465    desc = 'Stop the snippet session when leaving the buffer',
    466    buffer = bufnr,
    467    callback = function()
    468      M.stop()
    469    end,
    470  })
    471 end
    472 
    473 --- Expands the given snippet text.
    474 --- Refer to https://microsoft.github.io/language-server-protocol/specification/#snippet_syntax
    475 --- for the specification of valid input.
    476 ---
    477 --- Tabstops are highlighted with |hl-SnippetTabstop| and |hl-SnippetTabstopActive|.
    478 ---
    479 --- @param input string
    480 function M.expand(input)
    481  local snippet = G.parse(input)
    482  local snippet_text = {}
    483  ---@type string
    484  local base_indent = vim.api.nvim_get_current_line():match('^%s*') or ''
    485 
    486  -- Get the placeholders we should use for each tabstop index.
    487  --- @type table<integer, string>
    488  local placeholders = {}
    489  for _, child in ipairs(snippet.data.children) do
    490    local type, data = child.type, child.data
    491    if type == G.NodeType.Placeholder then
    492      --- @cast data vim.snippet.PlaceholderData
    493      local tabstop, value = data.tabstop, tostring(data.value)
    494      if placeholders[tabstop] and placeholders[tabstop] ~= value then
    495        error('Snippet has multiple placeholders for tabstop $' .. tabstop)
    496      end
    497      placeholders[tabstop] = value
    498    end
    499  end
    500 
    501  -- Keep track of tabstop nodes during expansion.
    502  --- @type table<integer, { placement: integer, range: Range4, choices?: string[] }[]>
    503  local tabstop_data = {}
    504 
    505  --- @param placement integer
    506  --- @param index integer
    507  --- @param placeholder? string
    508  --- @param choices? string[]
    509  local function add_tabstop(placement, index, placeholder, choices)
    510    tabstop_data[index] = tabstop_data[index] or {}
    511    local range = compute_tabstop_range(snippet_text, placeholder)
    512    table.insert(tabstop_data[index], { placement = placement, range = range, choices = choices })
    513  end
    514 
    515  --- Appends the given text to the snippet, taking care of indentation.
    516  ---
    517  --- @param text string|string[]
    518  local function append_to_snippet(text)
    519    local shiftwidth = vim.fn.shiftwidth()
    520    local curbuf = vim.api.nvim_get_current_buf()
    521    local expandtab = vim.bo[curbuf].expandtab
    522 
    523    local lines = {} --- @type string[]
    524    for i, line in ipairs(text_to_lines(text)) do
    525      -- Replace tabs by spaces.
    526      if expandtab then
    527        line = line:gsub('\t', (' '):rep(shiftwidth)) --- @type string
    528      end
    529      -- Add the base indentation.
    530      if i > 1 then
    531        line = base_indent .. line
    532      end
    533      lines[#lines + 1] = line
    534    end
    535 
    536    table.insert(snippet_text, table.concat(lines, '\n'))
    537  end
    538 
    539  for index, child in ipairs(snippet.data.children) do
    540    local type, data = child.type, child.data
    541    if type == G.NodeType.Tabstop then
    542      --- @cast data vim.snippet.TabstopData
    543      local placeholder = placeholders[data.tabstop]
    544      add_tabstop(index, data.tabstop, placeholder)
    545      if placeholder then
    546        append_to_snippet(placeholder)
    547      end
    548    elseif type == G.NodeType.Placeholder then
    549      --- @cast data vim.snippet.PlaceholderData
    550      local value = placeholders[data.tabstop]
    551      add_tabstop(index, data.tabstop, value)
    552      append_to_snippet(value)
    553    elseif type == G.NodeType.Choice then
    554      --- @cast data vim.snippet.ChoiceData
    555      add_tabstop(index, data.tabstop, nil, data.values)
    556    elseif type == G.NodeType.Variable then
    557      --- @cast data vim.snippet.VariableData
    558      -- Try to get the variable's value.
    559      local value = resolve_variable(data.name, data.default and tostring(data.default) or '')
    560      if not value then
    561        -- Unknown variable, make this a tabstop and use the variable name as a placeholder.
    562        value = data.name
    563        local tabstop_indexes = vim.tbl_keys(tabstop_data)
    564        local tabstop_index = math.max(unpack((#tabstop_indexes == 0 and { 0 }) or tabstop_indexes))
    565          + 1
    566        add_tabstop(index, tabstop_index, value)
    567      end
    568      append_to_snippet(value)
    569    elseif type == G.NodeType.Text then
    570      --- @cast data vim.snippet.TextData
    571      append_to_snippet(data.text)
    572    end
    573  end
    574 
    575  -- $0, which defaults to the end of the snippet, defines the final cursor position.
    576  -- Make sure the snippet has exactly one of these.
    577  if vim.tbl_contains(vim.tbl_keys(tabstop_data), 0) then
    578    assert(#tabstop_data[0] == 1, 'Snippet has multiple $0 tabstops')
    579  else
    580    add_tabstop(#snippet.data.children + 1, 0)
    581  end
    582 
    583  snippet_text = text_to_lines(snippet_text)
    584 
    585  -- Insert the snippet text.
    586  local bufnr = vim.api.nvim_get_current_buf()
    587  local cursor_row, cursor_col = cursor_pos()
    588  vim.api.nvim_buf_set_text(bufnr, cursor_row, cursor_col, cursor_row, cursor_col, snippet_text)
    589 
    590  -- Create the session.
    591  local snippet_extmark = vim.api.nvim_buf_set_extmark(bufnr, snippet_ns, cursor_row, cursor_col, {
    592    end_line = cursor_row + #snippet_text - 1,
    593    end_col = #snippet_text > 1 and #snippet_text[#snippet_text] or cursor_col + #snippet_text[1],
    594    right_gravity = false,
    595    end_right_gravity = true,
    596  })
    597  M._session = Session.new(bufnr, snippet_extmark, tabstop_data)
    598 
    599  -- Jump to the first tabstop.
    600  M.jump(1)
    601 end
    602 
    603 --- @alias vim.snippet.Direction -1 | 1
    604 
    605 --- Jumps to the next (or previous) placeholder in the current snippet, if possible.
    606 ---
    607 --- By default `<Tab>` is setup to jump if a snippet is active. The default mapping looks like:
    608 ---
    609 --- ```lua
    610 --- vim.keymap.set({ 'i', 's' }, '<Tab>', function()
    611 ---    if vim.snippet.active({ direction = 1 }) then
    612 ---      return '<Cmd>lua vim.snippet.jump(1)<CR>'
    613 ---    else
    614 ---      return '<Tab>'
    615 ---    end
    616 ---  end, { desc = '...', expr = true, silent = true })
    617 --- ```
    618 ---
    619 --- @param direction (vim.snippet.Direction) Navigation direction. -1 for previous, 1 for next.
    620 function M.jump(direction)
    621  -- Get the tabstop index to jump to.
    622  local dest_index = M._session and M._session:get_dest_index(direction)
    623  if not dest_index then
    624    return
    625  end
    626 
    627  -- Find the tabstop with the lowest range.
    628  local tabstops = M._session.tabstops[dest_index]
    629  local dest = tabstops[1]
    630  for _, tabstop in ipairs(tabstops) do
    631    local dest_range, range = dest:get_range(), tabstop:get_range()
    632    if (range[1] < dest_range[1]) or (range[1] == dest_range[1] and range[2] < dest_range[2]) then
    633      dest = tabstop
    634    end
    635  end
    636 
    637  -- Clear the autocommands so that we can move the cursor freely while selecting the tabstop.
    638  vim.api.nvim_clear_autocmds({ group = snippet_group, buffer = M._session.bufnr })
    639 
    640  M._session.current_tabstop = dest
    641  M._session:set_gravity()
    642  select_tabstop(dest)
    643 
    644  -- The cursor is not on a tabstop so exit the session.
    645  if dest.index == 0 then
    646    M.stop()
    647    return
    648  end
    649 
    650  -- Restore the autocommands.
    651  setup_autocmds(M._session.bufnr)
    652 end
    653 
    654 --- @class vim.snippet.ActiveFilter
    655 --- @field direction vim.snippet.Direction Navigation direction. -1 for previous, 1 for next.
    656 
    657 --- Returns `true` if there's an active snippet in the current buffer,
    658 --- applying the given filter if provided.
    659 ---
    660 --- @param filter? vim.snippet.ActiveFilter Filter to constrain the search with:
    661 --- - `direction` (vim.snippet.Direction): Navigation direction. Will return `true` if the snippet
    662 --- can be jumped in the given direction.
    663 --- @return boolean
    664 function M.active(filter)
    665  local active = M._session ~= nil and M._session.bufnr == vim.api.nvim_get_current_buf()
    666 
    667  local in_direction = true
    668  if active and filter and filter.direction then
    669    in_direction = M._session:get_dest_index(filter.direction) ~= nil
    670  end
    671 
    672  return active and in_direction
    673 end
    674 
    675 --- Exits the current snippet.
    676 function M.stop()
    677  if not M.active() then
    678    return
    679  end
    680 
    681  vim.api.nvim_clear_autocmds({ group = snippet_group, buffer = M._session.bufnr })
    682  vim.api.nvim_buf_clear_namespace(M._session.bufnr, snippet_ns, 0, -1)
    683 
    684  M._session = nil
    685 end
    686 
    687 return M