Skip to content

Commit

Permalink
feat(snippets): preserve relative indent in linked tabstops
Browse files Browse the repository at this point in the history
This matters for multiline tabstop text. For example:

- Snippet: $1\n  $1

- Tabstop text:
aa
bb

- Without preserving relative indent:
aa
bb
  aa
bb

- With preserving relative indent:
aa
bb
  aa
  bb
  • Loading branch information
echasnovski committed Jan 5, 2025
1 parent f968bec commit b1af49d
Show file tree
Hide file tree
Showing 4 changed files with 209 additions and 25 deletions.
6 changes: 4 additions & 2 deletions doc/mini-snippets.txt
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Features:
- Configurable mappings for jumping and stopping.
- Jumping wraps around the tabstops for easier navigation.
- Easy to reason rules for when session automatically stops.
- Text synchronization of linked tabstops.
- Text synchronization of linked tabstops preserving relative indent.
- Dynamic tabstop state visualization (current/visited/unvisited, etc.)
- Inline visualization of empty tabstops (requires Neovim>=0.10).
- Works inside comments by preserving comment leader on new lines.
Expand Down Expand Up @@ -355,7 +355,7 @@ try them out yourself. Here are steps for a basic demo:
"Basic": { "prefix": "ba", "body": "T1=$1 T2=$2 T0=$0" },
"Placeholders": { "prefix": "pl", "body": "T1=${1:aa}\nT2=${2:<$1>}" },
"Choices": { "prefix": "ch", "body": "T1=${1|a,b|} T2=${2|c,d|}" },
"Linked": { "prefix": "li", "body": "T1=$1\nT1=$1" },
"Linked": { "prefix": "li", "body": "T1=$1\n\tT1=$1" },
"Variables": { "prefix": "va", "body": "Runtime: $VIMRUNTIME\n" },
"Complex": {
"prefix": "co",
Expand Down Expand Up @@ -1003,6 +1003,8 @@ Prepare for snippet insert and do it:
- After first typed character the placeholder is removed and highlighting
changes from `MiniSnippetsCurrentReplace` to `MiniSnippetsCurrent`.
- Text in all tabstop nodes is synchronized with the reference one.
Relative indent of reference tabstop's text is preserved: all but first
lines in linked tabstops are reindented based on the first line indent.
Note: text sync is forced only for current tabstop (for performance).

- Jump with <C-l> / <C-h> to next / previous tabstop. Exact keys can be
Expand Down
77 changes: 58 additions & 19 deletions lua/mini/snippets.lua
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
--- - Configurable mappings for jumping and stopping.
--- - Jumping wraps around the tabstops for easier navigation.
--- - Easy to reason rules for when session automatically stops.
--- - Text synchronization of linked tabstops.
--- - Text synchronization of linked tabstops preserving relative indent.
--- - Dynamic tabstop state visualization (current/visited/unvisited, etc.)
--- - Inline visualization of empty tabstops (requires Neovim>=0.10).
--- - Works inside comments by preserving comment leader on new lines.
Expand Down Expand Up @@ -352,7 +352,7 @@
--- "Basic": { "prefix": "ba", "body": "T1=$1 T2=$2 T0=$0" },
--- "Placeholders": { "prefix": "pl", "body": "T1=${1:aa}\nT2=${2:<$1>}" },
--- "Choices": { "prefix": "ch", "body": "T1=${1|a,b|} T2=${2|c,d|}" },
--- "Linked": { "prefix": "li", "body": "T1=$1\nT1=$1" },
--- "Linked": { "prefix": "li", "body": "T1=$1\n\tT1=$1" },
--- "Variables": { "prefix": "va", "body": "Runtime: $VIMRUNTIME\n" },
--- "Complex": {
--- "prefix": "co",
Expand Down Expand Up @@ -1183,6 +1183,8 @@ end
--- - After first typed character the placeholder is removed and highlighting
--- changes from `MiniSnippetsCurrentReplace` to `MiniSnippetsCurrent`.
--- - Text in all tabstop nodes is synchronized with the reference one.
--- Relative indent of reference tabstop's text is preserved: all but first
--- lines in linked tabstops are reindented based on the first line indent.
--- Note: text sync is forced only for current tabstop (for performance).
---
--- - Jump with <C-l> / <C-h> to next / previous tabstop. Exact keys can be
Expand Down Expand Up @@ -2019,8 +2021,9 @@ H.session_init = function(session, full)

-- Prepare
if full then
-- Set buffer text
H.nodes_set_text(buf_id, session.nodes, session.extmark_id, H.get_indent())
-- Set buffer text preserving snippet text relative indent
local indent = H.get_indent(vim.fn.getline('.'):sub(1, vim.fn.col('.') - 1))
H.nodes_set_text(buf_id, session.nodes, session.extmark_id, indent)

-- No session if no input needed: single final tabstop without placeholder
if session.cur_tabstop == '0' then
Expand Down Expand Up @@ -2227,7 +2230,7 @@ H.session_sync_current_tabstop = function(session)

-- With present placeholder, decide whether there was a valid change (then
-- remove placeholder) or not (then no sync)
-- NOTE: Only current tabstop is synced *and* only if after its first edit is
-- NOTE: Only current tabstop is synced *and* only after its first edit is
-- mostly done to limit code complexity. This is a reasonable compromise
-- together with `parse()` syncing all tabstops in its normalization. Doing
-- more is better for cases which are outside of suggested workflow (like
Expand All @@ -2242,11 +2245,13 @@ H.session_sync_current_tabstop = function(session)
ref_node.placeholder = nil
end

-- Compute target text
-- Compute reference text: dedented version of reference node's text to later
-- reindent linked tabstops so that they preserve relative indent
local row, col, end_row, end_col = H.extmark_get_range(buf_id, ref_extmark_id)
local cur_text = vim.api.nvim_buf_get_text(0, row, col, end_row, end_col, {})
cur_text = table.concat(cur_text, '\n')
ref_node.text = cur_text
local ref_text = vim.api.nvim_buf_get_text(0, row, col, end_row, end_col, {})
ref_node.text = table.concat(ref_text, '\n')

ref_text = H.dedent(ref_text, row, col)

-- Sync nodes with current tabstop to have text from reference node
local cur_tabstop = session.cur_tabstop
Expand All @@ -2256,9 +2261,14 @@ H.session_sync_current_tabstop = function(session)
H.extmark_set_gravity(buf_id, n.extmark_id, 'expand')
if not (n.tabstop == cur_tabstop and n.extmark_id ~= ref_extmark_id) then return end

-- Ensure no placeholder because reference doesn't have one
if n.placeholder ~= nil then H.nodes_del(buf_id, n.placeholder) end
H.extmark_set_text(buf_id, n.extmark_id, 'inside', cur_text)
n.placeholder, n.text = nil, cur_text

-- Set reference text reindented based on the start line's indent
local cur_row, cur_col, cur_end_row, cur_end_col = H.extmark_get_range(buf_id, n.extmark_id)
local cur_text = H.reindent(vim.deepcopy(ref_text), cur_row, cur_col)
vim.api.nvim_buf_set_text(buf_id, cur_row, cur_col, cur_end_row, cur_end_col, cur_text)
n.placeholder, n.text = nil, table.concat(cur_text, '\n')
end
local sync_cleanup = function(n)
-- Make sure node's extmark doesn't move when setting later text
Expand All @@ -2275,7 +2285,7 @@ H.session_sync_current_tabstop = function(session)

-- Maybe show choices for empty tabstop at cursor
local cur_pos = vim.api.nvim_win_get_cursor(0)
if cur_text == '' and cur_pos[1] == (row + 1) and cur_pos[2] == col then H.show_completion(ref_node.choices) end
if ref_node.text == '' and cur_pos[1] == (row + 1) and cur_pos[2] == col then H.show_completion(ref_node.choices) end

-- Make highlighting up to date
H.session_update_hl(session)
Expand Down Expand Up @@ -2383,8 +2393,8 @@ H.nodes_set_text = function(buf_id, nodes, tracking_extmark_id, indent, cur_body

-- Adjust node's text and append it to currently set text
if n.text ~= nil then
-- Make all lines (not only first) in variable have same "outer" indent
local body_indent = n.var == nil and '' or cur_body_line:match('^%s*')
-- Make variable/tabstop lines preserve relative indent
local body_indent = (n.var == nil and n.tabstop == nil) and '' or H.get_indent(cur_body_line)
local new_text = n.text:gsub('\n', '\n' .. indent .. body_indent):gsub('\t', tab_text)
H.extmark_set_text(buf_id, tracking_extmark_id, 'right', new_text)

Expand Down Expand Up @@ -2481,11 +2491,9 @@ H.extmark_set_text = function(buf_id, ext_id, side, text)
end

-- Indent ---------------------------------------------------------------------
H.get_indent = function(lnum)
local line, comment_indent = vim.fn.getline(lnum or '.'), ''
-- Compute "indent at cursor"
local trunc_col = (lnum == nil or lnum == '.') and (vim.fn.col('.') - 1) or line:len()
line = line:sub(1, trunc_col)
H.get_indent = function(line)
line = line or vim.fn.getline('.')
local comment_indent = ''
-- Treat comment leaders as part of indent
for _, leader in ipairs(H.get_comment_leaders()) do
local cur_match = line:match('^%s*' .. vim.pesc(leader) .. '%s*')
Expand Down Expand Up @@ -2520,6 +2528,37 @@ H.get_comment_leaders = function()
return res
end

H.dedent = function(lines, row, col)
if #lines <= 1 then return lines end
-- Compute common (smallest) indent width. Not accounting for actual indent
-- characters is easier and works for common cases but breaks for weird ones,
-- like `# a\n\t# b`.
local init_line_at_pos = vim.fn.getline(row + 1):sub(1, col)
local indent_width = H.get_indent(init_line_at_pos):len()
for i = 2, #lines do
-- Don't count "only indent" lines (i.e. blank with/without comment leader)
local cur_indent = H.get_indent(lines[i])
if cur_indent:len() < indent_width and cur_indent ~= lines[i] then indent_width = cur_indent:len() end
end

for i = 2, #lines do
lines[i] = lines[i]:sub(indent_width + 1)
end

return lines
end

H.reindent = function(lines, row, col)
if #lines <= 1 then return lines end
local init_line_at_pos = vim.fn.getline(row + 1):sub(1, col)
local indent = H.get_indent(init_line_at_pos)
for i = 2, #lines do
-- NOTE: reindent even "pure indent" lines, as it seems more natural
lines[i] = indent .. lines[i]
end
return lines
end

-- Validators -----------------------------------------------------------------
H.is_string = function(x) return type(x) == 'string' end

Expand Down
4 changes: 2 additions & 2 deletions readmes/mini-snippets.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ https://github.com/user-attachments/assets/2cb38960-a26c-48ae-83cd-5fbcaa57d1cf
- Configurable mappings for jumping and stopping.
- Jumping wraps around the tabstops for easier navigation.
- Easy to reason rules for when session automatically stops.
- Text synchronization of linked tabstops.
- Text synchronization of linked tabstops preserving relative indent.
- Dynamic tabstop state visualization (current/visited/unvisited, etc.)
- Inline visualization of empty tabstops (requires Neovim>=0.10).
- Works inside comments by preserving comment leader on new lines.
Expand Down Expand Up @@ -100,7 +100,7 @@ try them out yourself. Here are extra steps for a basic demo:
"Basic": { "prefix": "ba", "body": "T1=$1 T2=$2 T0=$0" },
"Placeholders": { "prefix": "pl", "body": "T1=${1:aa}\nT2=${2:<$1>}" },
"Choices": { "prefix": "ch", "body": "T1=${1|a,b|} T2=${2|c,d|}" },
"Linked": { "prefix": "li", "body": "T1=$1\nT1=$1" },
"Linked": { "prefix": "li", "body": "T1=$1\n\tT1=$1" },
"Variables": { "prefix": "va", "body": "Runtime: $VIMRUNTIME\n" },
"Complex": {
"prefix": "co",
Expand Down
147 changes: 145 additions & 2 deletions tests/test_snippets.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1801,7 +1801,7 @@ T['default_insert()']['indent']['respects manual lookup entries'] = function()
validate_state('i', { ' \tT1=tab', ' \tstop', ' \tAAA=aaa', ' \tbbb' }, { 2, 6 })
end

T['default_insert()']['indent']['is the same for all lines in variables'] = function()
T['default_insert()']['indent']['preserves relative indent in variables'] = function()
child.fn.setenv('AA', 'aa\nbb\n\tcc')
child.fn.setenv('BB', 'bb\n')

Expand All @@ -1814,9 +1814,11 @@ T['default_insert()']['indent']['is the same for all lines in variables'] = func
validate(' $AA', { ' aa', ' bb', ' \tcc' })
validate('\t$AA', { '\taa', '\tbb', '\t\tcc' })
validate(' $BB', { ' bb', ' ' })

validate('text\n $AA', { 'text', ' aa', ' bb', ' \tcc' })

validate('$AA\n\t$AA', { 'aa', 'bb', '\tcc', '\taa', '\tbb', '\t\tcc' })
validate('\t$AA\n$AA', { '\taa', '\tbb', '\t\tcc', 'aa', 'bb', '\tcc' })

validate(' ${XX:$AA}', { ' aa', ' bb', ' \tcc' })
validate('${XX: $AA}', { ' aa', ' bb', ' \tcc' })
validate('${XX: ${UU:\t$AA}}', { ' \taa', ' \tbb', ' \t\tcc' })
Expand All @@ -1832,15 +1834,64 @@ T['default_insert()']['indent']['is the same for all lines in variables'] = func
type_keys('i', '# ')
validate('$BB$AA', { '# bb', '# aa', '# bb', '# \tcc' })

validate('$AA\n# $AA', { 'aa', 'bb', '\tcc', '# aa', '# bb', '# \tcc' })

-- As there is no indent "inside snippet body", AA is not reindented
-- This might be not a good behavior, but fix seems complicated
validate(' $BB$AA', { ' bb', ' aa', 'bb', '\tcc' })

-- Should work with decreasing indent in variable lines
child.fn.setenv('YY', ' xx\nyy')
validate('\t$YY', { '\t xx', '\tyy' })

-- Should work with 'expandtab'
child.bo.expandtab, child.bo.shiftwidth = true, 2
validate('\t$AA', { ' aa', ' bb', ' cc' })
end

T['default_insert()']['indent']['preserves relative indent in looked up tabstop text'] = function()
local validate = function(body, tabstop_text, ref_lines)
default_insert({ body = body }, { lookup = { ['1'] = tabstop_text } })
validate_state('i', ref_lines, nil)
ensure_clean_state()
end

validate(' $1', 'aa\nbb', { ' aa', ' bb' })
validate('\t$1', 'aa\nbb', { '\taa', '\tbb' })
validate('text\n $1', 'aa\nbb', { 'text', ' aa', ' bb' })

validate(' $1', ' aa\nbb', { ' aa', ' bb' })
validate(' $1', 'aa\n bb', { ' aa', ' bb' })

-- Should work with linked tabstops
validate('$1\n\t$1', 'aa\nbb', { 'aa', 'bb', '\taa', '\tbb' })
validate('\t$1\n$1', 'aa\nbb', { '\taa', '\tbb', 'aa', 'bb' })

validate('$1\n${2:\t$1}', 'aa\nbb', { 'aa', 'bb', '\taa', '\tbb' })
validate('$1\n\t${2:$1}', 'aa\nbb', { 'aa', 'bb', '\taa', '\tbb' })

-- Should work with variables
child.fn.setenv('XX', 'xx\n ')
validate('$XX$1', 'aa\nbb', { 'xx', ' aa', ' bb' })

-- Should work in placeholders
validate('${2: $1}', 'aa\nbb', { ' aa', ' bb' })
validate('${AA: $1}', 'aa\nbb', { ' aa', ' bb' })

-- Should also work with comments
child.bo.commentstring = '# %s'
type_keys('i', '# ')
validate('$1', 'aa\nbb', { '# aa', '# bb' })

validate('$1\n# $1', 'aa\nbb', { 'aa', 'bb', '# aa', '# bb' })

-- Should work with 'expandtab'
child.bo.expandtab, child.bo.shiftwidth = true, 2
validate('\t$1', 'aa\nbb', { ' aa', ' bb' })
validate('\t$1', 'aa\n\tbb', { ' aa', ' bb' })
child.bo.expandtab = false
end

T['default_insert()']['triggers start/stop events'] = function()
local make_ref_data = function(snippet_body)
return { session = { insert_args = { snippet = { body = snippet_body } } } }
Expand Down Expand Up @@ -1877,6 +1928,27 @@ T['default_insert()']['respects tab-related options'] = function()
validate_state('i', { '\ttext\t\t' }, { 1, 7 })
end

T['default_insert()']['keeps node text up to date'] = function()
child.fn.setenv('AA', 'aa\nbb')
child.bo.expandtab, child.bo.shiftwidth = true, 2

default_insert({ body = '$1\n\t$AA' })
local ref_nodes = { { tabstop = '1' }, { text = '\n ' }, { text = 'aa\n bb' }, { tabstop = '0' } }
eq_partial_tbl(get().nodes, ref_nodes)

type_keys('x')
ref_nodes = { { tabstop = '1', text = 'x' }, { text = '\n ' }, { text = 'aa\n bb' }, { tabstop = '0' } }
eq_partial_tbl(get().nodes, ref_nodes)

ensure_clean_state()

-- Linked tabstops with relative indents
default_insert({ body = '$1\n\t$1' })
type_keys('xx<CR>yy')
ref_nodes = { { tabstop = '1', text = 'xx\nyy' }, { text = '\n ' }, { text = 'xx\n yy' }, { tabstop = '0' } }
eq_partial_tbl(get().nodes, ref_nodes)
end

T['default_insert()']['shows tabstop choices after start'] = function()
-- Called in Insert mode
type_keys('i')
Expand Down Expand Up @@ -4041,6 +4113,77 @@ T['Session']['linked tabstops']['handle text change in not reference node'] = fu
validate_state('i', { 'T1=yy T1=yy T1=yy' }, { 1, 17 })
end

T['Session']['relative indent'] = new_set()

T['Session']['relative indent']['is preserved'] = function()
start_session('\tT1=$1\n\t\t$1\n$1')
validate_state('i', { '\tT1=', '\t\t', '' }, { 1, 4 })

type_keys('xx', '<CR>')
validate_state('i', { '\tT1=xx', '\t', '\t\txx', '\t\t', 'xx', '' }, { 2, 1 })

type_keys('yy')
validate_state('i', { '\tT1=xx', '\tyy', '\t\txx', '\t\tyy', 'xx', 'yy' }, { 2, 3 })

-- Should adjust on every sync (even if typing outside of tabstop range)
set_cursor(3, 1)
type_keys('<BS>')
validate_state('i', { '\tT1=xx', '\tyy', '\txx', '\tyy', 'xx', 'yy' }, { 3, 0 })
end

T['Session']['relative indent']['is preserved inside placeholder'] = function()
start_session('$1\n\t${2:$1}')
type_keys('aa<CR>bb')
validate_state('i', { 'aa', 'bb', '\taa', '\tbb' }, { 2, 2 })
ensure_clean_state()

start_session('$1\n${2:\t$1}')
type_keys('aa<CR>bb')
validate_state('i', { 'aa', 'bb', '\taa', '\tbb' }, { 2, 2 })
end

T['Session']['relative indent']['dedents reference text based on smallest indent'] = function()
start_session('\t$1\n\t\t$1')
type_keys('aa', '<CR><BS>', 'bb')
validate_state('i', { '\taa', 'bb', '\t\taa', '\t\tbb' }, { 2, 2 })
type_keys('<Left><Left>', '\t')
validate_state('i', { '\taa', '\tbb', '\t\taa', '\t\tbb' }, { 2, 1 })
type_keys('\t')
validate_state('i', { '\taa', '\t\tbb', '\t\taa', '\t\t\tbb' }, { 2, 2 })
end

T['Session']['relative indent']['dedents reference text ignoring "pure indent" lines during dedent'] = function()
type_keys('i', ' ')
start_session('$1\n\t$1')

type_keys('aa<CR><CR>bb')
-- "Pure indent" lines should still be reindented
validate_state('i', { ' aa', '', ' bb', ' \taa', ' \t', ' \tbb' }, { 3, 4 })
end

T['Session']['relative indent']['respects comments'] = function()
child.bo.commentstring = '# %s'

type_keys('i', ' # ')
start_session('$1\n\t$1')
validate_state('i', { ' # ', ' # \t' }, { 1, 5 })

type_keys('aa<CR>bb')
validate_state('i', { ' # aa', ' bb', ' # \taa', ' # \tbb' }, { 2, 4 })

type_keys('<Left><Left># ')
validate_state('i', { ' # aa', ' # bb', ' # \taa', ' # \tbb' }, { 2, 5 })

type_keys(' ')
validate_state('i', { ' # aa', ' # bb', ' # \taa', ' # \t bb' }, { 2, 6 })
end

T['Session']['relative indent']['does not use tabstop text during dedent'] = function()
start_session(' $1\n\t$1')
type_keys(' aa', '<CR>', 'bb')
validate_state('i', { ' aa', ' bb', '\t aa', '\t bb' }, { 2, 6 })
end

T['Session']['nesting'] = new_set({ hooks = { pre_case = setup_event_log } })

T['Session']['nesting']['works and triggers events'] = function()
Expand Down

0 comments on commit b1af49d

Please sign in to comment.