diff --git a/doc/mini-snippets.txt b/doc/mini-snippets.txt index 7394251f..ce617cfd 100644 --- a/doc/mini-snippets.txt +++ b/doc/mini-snippets.txt @@ -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. @@ -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", @@ -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 / to next / previous tabstop. Exact keys can be diff --git a/lua/mini/snippets.lua b/lua/mini/snippets.lua index 9284ed9f..4a910f09 100644 --- a/lua/mini/snippets.lua +++ b/lua/mini/snippets.lua @@ -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. @@ -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", @@ -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 / to next / previous tabstop. Exact keys can be @@ -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 @@ -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 @@ -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 @@ -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 @@ -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) @@ -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) @@ -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*') @@ -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 diff --git a/readmes/mini-snippets.md b/readmes/mini-snippets.md index e7119eb7..891841d6 100644 --- a/readmes/mini-snippets.md +++ b/readmes/mini-snippets.md @@ -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. @@ -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", diff --git a/tests/test_snippets.lua b/tests/test_snippets.lua index fedeb05f..50768a33 100644 --- a/tests/test_snippets.lua +++ b/tests/test_snippets.lua @@ -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') @@ -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' }) @@ -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 } } } } @@ -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('xxyy') + 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') @@ -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', '') + 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('') + 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('aabb') + validate_state('i', { 'aa', 'bb', '\taa', '\tbb' }, { 2, 2 }) + ensure_clean_state() + + start_session('$1\n${2:\t$1}') + type_keys('aabb') + 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', '', 'bb') + validate_state('i', { '\taa', 'bb', '\t\taa', '\t\tbb' }, { 2, 2 }) + type_keys('', '\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('aabb') + -- "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('aabb') + validate_state('i', { ' # aa', ' bb', ' # \taa', ' # \tbb' }, { 2, 4 }) + + type_keys('# ') + 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', '', '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()