--- *mini.splitjoin* Split and join arguments --- *MiniSplitjoin* --- --- MIT License Copyright (c) 2023 Evgeni Chasnovski --- --- ============================================================================== --- --- Features: --- - Mappings and Lua functions that modify arguments (regions inside brackets --- between allowed separators) under cursor. --- --- Supported actions: --- - Toggle - split if arguments are on single line, join otherwise. --- Main supported function of the module. See |MiniSplitjoin.toggle()|. --- - Split - make every argument separator be on end of separate line. --- See |MiniSplitjoin.split()|. --- - Join - make all arguments be on single line. --- See |MiniSplitjoin.join()|. --- --- - Mappings are dot-repeatable in Normal mode and work in Visual mode. --- --- - Customizable argument detection (see |MiniSplitjoin.config.detect|): --- - Which brackets can contain arguments. --- - Which strings can separate arguments. --- - Which regions are excluded when looking for separators (like inside --- nested brackets or quotes). --- --- - Customizable pre and post hooks for both split and join. See `split` and --- `join` in |MiniSplitjoin.config|. There are several built-in ones --- in |MiniSplitjoin.gen_hook|. --- --- - Works inside comments by using modified notion of indent. --- See |MiniSplitjoin.get_indent_part()|. --- --- - Provides low-level Lua functions for split and join at positions. --- See |MiniSplitjoin.split_at()| and |MiniSplitjoin.join_at()|. --- --- Notes: --- - Search for arguments is done using Lua patterns (regex-like approach). --- Certain amount of false positives is to be expected. --- --- - This module is mostly designed around |MiniSplitjoin.toggle()|. If target --- split positions are on different lines, join first and then split. --- --- - Actions can be done on Visual mode selection, which mostly present as --- a safety route in case of incorrect detection of initial region. --- It uses |MiniSplitjoin.get_visual_region()| which treats selection as full --- brackets (include brackets in selection). --- --- # Setup ~ --- --- This module needs a setup with `require('mini.splitjoin').setup({})` (replace --- `{}` with your `config` table). It will create global Lua table `MiniSplitjoin` --- which you can use for scripting or manually (with `:lua MiniSplitjoin.*`). --- --- See |MiniSplitjoin.config| for available config settings. --- --- You can override runtime config settings (like action hooks) locally to --- buffer inside `vim.b.minisplitjoin_config` which should have same structure --- as `MiniSplitjoin.config`. See |mini.nvim-buffer-local-config| for more details. --- --- # Comparisons ~ --- --- - 'FooSoft/vim-argwrap': --- - Mostly has the same design as this module. --- - Doesn't work inside comments, while this module does. --- - Has more built-in ways to control split and join, while this module --- intentionally provides only handful. --- - 'AndrewRadev/splitjoin.vim': --- - More oriented towards language-depended transformations, while this --- module intntionally deals with more generic text-related functionality. --- - 'Wansmer/treesj': --- - Operates based on tree-sitter nodes. This is more accurate in --- some edge cases, but **requires** tree-sitter parser. --- - Doesn't work inside comments or strings. --- --- # Disabling~ --- --- To disable, set `g:minisplitjoin_disable` (globally) or `b:minisplitjoin_disable` --- (for a buffer) to `v:true`. Considering high number of different scenarios --- and customization intentions, writing exact rules for disabling module's --- functionality is left to user. See |mini.nvim-disabling-recipes| for common --- recipes. --- - POSITION - table with fields and containing line and column --- numbers respectively. Both are 1-indexed. Example: `{ line = 2, col = 1 }`. --- --- - REGION - table representing region in a buffer. Fields: and --- for inclusive start and end positions. Example: > --- --- { from = { line = 1, col = 1 }, to = { line = 2, col = 1 } } ---@tag MiniSplitjoin-glossary ---@alias __splitjoin_options table|nil Options. Has structure from |MiniSplitjoin.config| --- inheriting its default values. --- --- Following extra optional fields are allowed: --- - `(table)` - position at which to find smallest bracket region. --- See |MiniSplitjoin-glossary| for the structure. --- Default: cursor position. --- - `(table)` - region at which to perform action. Assumes inclusive --- both start at left bracket and end at right bracket. --- See |MiniSplitjoin-glossary| for the structure. --- Default: `nil` to automatically detect region. ---@alias __splitjoin_hook_brackets - `(table)` - array of bracket patterns indicating on which --- brackets action should be made. Has same structure as `brackets` --- in |MiniSplitjoin.config.detect|. --- Default: `MiniSplitjoin.config.detect.brackets`. ---@diagnostic disable:undefined-field ---@diagnostic disable:discard-returns ---@diagnostic disable:unused-local -- Module definition ========================================================== local MiniSplitjoin = {} local H = {} --- Module setup --- ---@param config table|nil Module config table. See |MiniSplitjoin.config|. --- ---@usage `require('mini.splitjoin').setup({})` (replace `{}` with your `config` table) MiniSplitjoin.setup = function(config) -- Export module _G.MiniSplitjoin = MiniSplitjoin -- Setup config config = H.setup_config(config) -- Apply config H.apply_config(config) end --- Module config --- --- Default values: ---@eval return MiniDoc.afterlines_to_code(MiniDoc.current.eval_section) ---@text *MiniSplitjoin.config.detect* --- # Detection ~ --- --- The table at `config.detect` controls how arguments are detected using Lua --- patterns. General idea is to convert whole buffer into a single line, --- perform string search, and convert results back into 2d positions. --- --- Example configuration: > --- --- require('mini.splitjoin').setup({ --- detect = { --- -- Detect only inside balanced parenthesis --- brackets = { '%b()' }, --- --- -- Allow both `,` and `;` to separate arguments --- separator = '[,;]', --- --- -- Make any separator define an argument --- exclude_regions = {}, --- }, --- }) --- --- ## Outer brackets ~ --- --- `detect.brackets` is an array of Lua patterns used to find enclosing region. --- It is done by traversing whole buffer to find the smallest region matching --- any supplied pattern. --- --- Default: `nil`, inferred as `{ '%b()', '%b[]', '%b{}' }`. --- So an argument can be inside a balanced `()`, `[]`, or `{}`. --- --- Example: `brackets = { '%b()' }` will search for arguments only inside --- balanced `()`. --- --- ## Separator ~ --- --- `detect.separator` is a single Lua pattern defining which strings should be --- treated as argument separators. --- --- Empty string in `detect.separator` will result in only surrounding brackets --- used as separators. --- --- Only end of pattern match will be used as split/join positions. --- --- Default: `','`. So an argument can be separated only with comma. --- --- Example: `separator = { '[,;]' }` will treat both `,` and `;` as separators. --- --- ## Excluded regions ~ --- --- `detect.exclude_regions` is an array of Lua patterns for sub-regions from which --- to exclude separators. Enables correct detection in case of nested brackets --- and quotes. --- --- Default: `nil`; inferred as `{ '%b()', '%b[]', '%b{}', '%b""', "%b''" }`. --- So a separator **can not** be inside a balanced `()`, `[]`, `{}` (representing --- nested argument regions) or `""`, `''` (representing strings). --- --- Example: `exclude_regions = {}` will not exclude any regions. So in case of --- `f(a, { b, c })` it will detect both commas as argument separators. --- --- # Hooks ~ --- --- `split.hooks_pre`, `split.hooks_post`, `join.hooks_pre`, and `join.hooks_post` --- are arrays of hook functions. If empty (default) no hook is applied. --- --- Hooks should take and return array of positions. See |MiniSplitjoin-glossary|. --- --- They can be used to tweak actions: --- --- - Pre-hooks are called before action. Each is applied on the output of --- previous one. Input of first hook are detected split/join positions. --- Output of last one is actually used to perform split/join. --- --- - Post-hooks are called after action. Each is applied on the output of --- previous one. Input of first hook are split/join positions from actual --- action plus its region's right end as last position (for easier hook code). --- Output of last one is used as action return value. --- --- For more specific details see |MiniSplitjoin.split()| and |MiniSplitjoin.join()|. --- --- See |MiniSplitjoin.gen_hook| for generating common hooks with examples. MiniSplitjoin.config = { -- Module mappings. Use `''` (empty string) to disable one. -- Created for both Normal and Visual modes. mappings = { toggle = 'gS', split = '', join = '', }, -- Detection options: where split/join should be done detect = { -- Array of Lua patterns to detect region with arguments. -- Default: { '%b()', '%b[]', '%b{}' } brackets = nil, -- String Lua pattern defining argument separator separator = ',', -- Array of Lua patterns for sub-regions to exclude separators from. -- Enables correct detection in presence of nested brackets and quotes. -- Default: { '%b()', '%b[]', '%b{}', '%b""', "%b''" } exclude_regions = nil, }, -- Split options split = { hooks_pre = {}, hooks_post = {}, }, -- Join options join = { hooks_pre = {}, hooks_post = {}, }, } --minidoc_afterlines_end --- Toggle arguments --- --- Overview: --- - Detect region at input position: either by using supplied `opts.region` or --- by finding smallest bracketed region surrounding position. --- See |MiniSplitjoin.config.detect| for more details. --- - If region spans single line, use |MiniSplitjoin.split()| with found region. --- Otherwise use |MiniSplitjoin.join()|. --- ---@param opts __splitjoin_options --- ---@return any Output of chosen `split()` or `join()` action. MiniSplitjoin.toggle = function(opts) if H.is_disabled() then return end opts = H.get_opts(opts) local region = opts.region or H.find_smallest_bracket_region(opts.position, opts.detect.brackets) if region == nil then return end opts.region = region if region.from.line == region.to.line then return MiniSplitjoin.split(opts) else return MiniSplitjoin.join(opts) end end --- Split arguments --- --- Overview: --- - Detect region: either by using supplied `opts.region` or by finding smallest --- bracketed region surrounding input position (cursor position by default). --- See |MiniSplitjoin.config.detect| for more details. --- --- - Find separator positions using `separator` and `exclude_regions` from `opts`. --- Both brackets are treated as separators. --- See |MiniSplitjoin.config.detect| for more details. --- Note: stop if no separator positions are found. --- --- - Modify separator positions to represent split positions. Last split position --- (which is inferred from right bracket) is moved one column to left so that --- right bracket would move on new line. --- --- - Apply all hooks from `opts.split.hooks_pre`. Each is applied on the output of --- previous one. Input of first hook is split positions from previous step. --- Output of last one is used as split positions in next step. --- --- - Split and update split positions with |MiniSplitjoin.split_at()|. --- --- - Apply all hooks from `opts.split.hooks_post`. Each is applied on the output of --- previous one. Input of first hook is split positions from previous step plus --- region's right end (for easier hook code). --- Output of last one is used as function return value. --- --- Note: --- - By design, it doesn't detect if argument **should** be split, so application --- on arguments spanning multiple lines can lead to undesirable result. --- ---@param opts __splitjoin_options --- ---@return any Output of last `opts.split.hooks_post` or `nil` if no split positions --- found. Default: return value of |MiniSplitjoin.split_at()| application. MiniSplitjoin.split = function(opts) if H.is_disabled() then return end opts = H.get_opts(opts) local region = opts.region or H.find_smallest_bracket_region(opts.position, opts.detect.brackets) if region == nil then return nil end local positions = H.find_split_positions(region, opts.detect.separator, opts.detect.exclude_regions) if #positions == 0 then return nil end -- Call pre-hooks for _, hook in ipairs(opts.split.hooks_pre) do positions = hook(positions) end -- Split at positions local split_positions = MiniSplitjoin.split_at(positions) -- Call post-hooks to tweak splits. Add right bracket for easier hook code. local last = split_positions[#split_positions] local last_next_line = vim.fn.getline(last.line + 1) local new_col = MiniSplitjoin.get_indent_part(last_next_line):len() + 1 table.insert(split_positions, { line = last.line + 1, col = new_col }) for _, hook in ipairs(opts.split.hooks_post) do split_positions = hook(split_positions) end return split_positions end --- Join arguments --- --- Overview: --- - Detect region: either by using supplied `opts.region` or by finding smallest --- bracketed region surrounding input position (cursor position by default). --- See |MiniSplitjoin.config.detect| for more details. --- --- - Compute join positions to be line ends of all but last region lines. --- Note: stop if no join positions are found. --- --- - Apply all hooks from `opts.join.hooks_pre`. Each is applied on the output --- of previous one. Input of first hook is join positions from previous step. --- Output of last one is used as join positions in next step. --- --- - Join and update join positions with |MiniSplitjoin.join_at()|. --- --- - Apply all hooks from `opts.join.hooks_post`. Each is applied on the output --- of previous one. Input of first hook is join positions from previous step --- plus region's right end for easier hook code. --- Output of last one is used as function return value. --- ---@param opts __splitjoin_options --- ---@return any Output of last `opts.split.hooks_post` or `nil` of no join positions --- found. Default: return value of |MiniSplitjoin.join_at()| application. MiniSplitjoin.join = function(opts) if H.is_disabled() then return end opts = H.get_opts(opts) local region = opts.region or H.find_smallest_bracket_region(opts.position, opts.detect.brackets) if region == nil then return nil end local positions = H.find_join_positions(region) if #positions == 0 then return nil end -- Call pre-hooks for _, hook in ipairs(opts.join.hooks_pre) do positions = hook(positions) end -- Join at positions local join_positions = MiniSplitjoin.join_at(positions) -- Call post-hooks to tweak joins. Add right bracket for easier hook code. local last = join_positions[#join_positions] table.insert(join_positions, { line = last.line, col = last.col + 1 }) for _, hook in ipairs(opts.join.hooks_post) do join_positions = hook(join_positions) end return join_positions end --- Generate common hooks --- --- This is a table with function elements. Call to actually get hook. --- --- All generated post-hooks return updated versions of their input reflecting --- changes done inside hook. --- --- Example for `lua` filetype (place it in 'lua.lua' filetype plugin, |ftplugin|): > --- --- local gen_hook = MiniSplitjoin.gen_hook --- local curly = { brackets = { '%b{}' } } --- --- -- Add trailing comma when splitting inside curly brackets --- local add_comma_curly = gen_hook.add_trailing_separator(curly) --- --- -- Delete trailing comma when joining inside curly brackets --- local del_comma_curly = gen_hook.del_trailing_separator(curly) --- --- -- Pad curly brackets with single space after join --- local pad_curly = gen_hook.pad_brackets(curly) --- --- -- Create buffer-local config --- vim.b.minisplitjoin_config = { --- split = { hooks_post = { add_comma_curly } }, --- join = { hooks_post = { del_comma_curly, pad_curly } }, --- } MiniSplitjoin.gen_hook = {} --- Generate hook to pad brackets --- --- This is a join post-hook. Use in `join.hooks_post` of |MiniSplitjoin.config|. --- ---@param opts table|nil Options. Possible fields: --- - `(string)` - pad to add after first and before last join positions. --- Default: `' '` (single space). --- __splitjoin_hook_brackets --- ---@return function A hook which adds inner pad to first and last join positions and --- returns updated input join positions. MiniSplitjoin.gen_hook.pad_brackets = function(opts) opts = opts or {} local pad = opts.pad or ' ' local brackets = opts.brackets or H.get_opts(opts).detect.brackets local n_pad = pad:len() return function(join_positions) -- Act only on actual join local n_pos = #join_positions if n_pos == 0 or pad == '' then return join_positions end -- Act only if brackets are matched. First join position should be exactly -- on left bracket, last - just before right bracket. local first, last = join_positions[1], join_positions[n_pos] local brackets_matched = H.is_positions_inside_brackets(first, last, brackets) if not brackets_matched then return join_positions end -- Pad only in case of non-trivial join if first.line == last.line and (last.col - first.col) <= 1 then return join_positions end -- Add pad after left and before right edges H.set_text(first.line - 1, last.col - 1, first.line - 1, last.col - 1, { pad }) H.set_text(first.line - 1, first.col, first.line - 1, first.col, { pad }) -- Update `join_positions` to reflect text change -- - Account for left pad for i = 2, n_pos do join_positions[i].col = join_positions[i].col + n_pad end -- - Account for right pad join_positions[n_pos].col = join_positions[n_pos].col + n_pad return join_positions end end --- Generate hook to add trailing separator --- --- This is a split post-hook. Use in `split.hooks_post` of |MiniSplitjoin.config|. --- ---@param opts table|nil Options. Possible fields: --- - `(string)` - separator to add before last split position. --- Default: `','`. --- __splitjoin_hook_brackets --- ---@return function A hook which adds separator before last split position and --- returns updated input split positions. MiniSplitjoin.gen_hook.add_trailing_separator = function(opts) opts = opts or {} local sep = opts.sep or ',' local brackets = opts.brackets or H.get_opts(opts).detect.brackets return function(split_positions) -- Add only in case there is at least one argument local n_pos = #split_positions if n_pos < 3 then return split_positions end -- Act only if brackets are matched local first, last = split_positions[1], split_positions[n_pos] local brackets_matched = H.is_positions_inside_brackets(first, last, brackets) if not brackets_matched then return split_positions end -- Act only if there is no trailing separator already local target_line = vim.fn.getline(last.line - 1) local target_col = target_line:find(vim.pesc(sep) .. '$') if target_col ~= nil then return split_positions end -- Add trailing separator local col = target_line:len() H.set_text(last.line - 2, col, last.line - 2, col, { sep }) -- Don't update `split_positions`, as appending to line has no effect return split_positions end end --- Generate hook to delete trailing separator --- --- This is a join post-hook. Use in `join.hooks_post` of |MiniSplitjoin.config|. --- ---@param opts table|nil Options. Possible fields: --- - `(string)` - separator to remove before last join position. --- Default: `','`. --- __splitjoin_hook_brackets --- ---@return function A hook which adds separator before last split position and --- returns updated input split positions. MiniSplitjoin.gen_hook.del_trailing_separator = function(opts) opts = opts or {} local sep = opts.sep or ',' local brackets = opts.brackets or H.get_opts(opts).detect.brackets local n_sep = sep:len() return function(join_positions) -- Act only on actual join local n_pos = #join_positions if n_pos == 0 then return join_positions end -- Act only if brackets are matched local first, last = join_positions[1], join_positions[n_pos] local brackets_matched = H.is_positions_inside_brackets(first, last, brackets) if not brackets_matched then return join_positions end -- Act only if there is matched trailing separator local target_line = vim.fn.getline(last.line):sub(1, last.col - 1) local target_col = target_line:find(vim.pesc(sep) .. '%s*$') if target_col == nil then return join_positions end -- Remove trailing separator H.set_text(last.line - 1, target_col - 1, last.line - 1, target_col - 1 + n_sep, {}) -- Update `join_positions` to reflect text change. Update last as it moved. -- Do not update second to last because it didn't affect what was tracked. join_positions[n_pos] = { line = last.line, col = last.col - n_sep } return join_positions end end --- Split at positions --- --- Overview: --- - For each position move all characters after it to next line and make it have --- same indent as current one (see |MiniSplitjoin.get_indent_part()|). --- Also remove trailing whitespace at position line. --- --- - Increase indent of inner lines by a single pad: tab in case of |noexpandtab| --- or |shiftwidth()| number of spaces otherwise. --- --- Notes: --- - Cursor is adjusted to follow text updates. --- - Use output of this function to keep track of input positions. --- ---@param positions table Array of positions at which to perform split. --- See |MiniSplitjoin-glossary| for their structure. Note: they don't have --- to be ordered, but first and last ones will be used to infer lines for --- which indent will be increased. --- ---@return table Array of new positions to where input `positions` were moved. MiniSplitjoin.split_at = function(positions) local n_pos = #positions if n_pos == 0 then return {} end -- Cache values that might change local cursor_extmark = H.put_extmark_at_positions({ H.get_cursor_pos() })[1] local input_extmarks = H.put_extmark_at_positions(positions) -- Split at extmark positions for i = 1, n_pos do H.split_at_extmark(input_extmarks[i]) end -- Increase indent of inner lines local first_new_pos = H.get_extmark_pos(input_extmarks[1]) local last_new_pos = H.get_extmark_pos(input_extmarks[n_pos]) H.increase_indent(first_new_pos.line + 1, last_new_pos.line) -- Put cursor back on tracked position H.put_cursor_at_extmark(cursor_extmark) -- Reconstruct input positions local res = vim.tbl_map(H.get_extmark_pos, input_extmarks) vim.api.nvim_buf_clear_namespace(0, H.ns_id, 0, -1) return res end --- Join at positions --- --- Overview: --- - For each position join its line with the next line. Joining is done by --- replacing trailing whitespace of the line and indent of its next line --- (see |MiniSplitjoin.get_indent_part()|) with a pad string (single space except --- empty string for first and last positions). To adjust this, use hooks --- (for example, see |MiniSplitjoin.gen_hook.pad_brackets()|). --- --- Notes: --- - Cursor is adjusted to follow text updates. --- - Use output of this function to keep track of input positions. --- ---@param positions table Array of positions at which to perform join. --- See |MiniSplitjoin-glossary| for their structure. Note: they don't have --- to be ordered, but first and last ones will have different pad string. --- ---@return table Array of new positions to where input `positions` were moved. MiniSplitjoin.join_at = function(positions) local n_pos = #positions if n_pos == 0 then return {} end -- Cache values that might change local cursor_extmark = H.put_extmark_at_positions({ H.get_cursor_pos() })[1] local input_extmarks = H.put_extmark_at_positions(positions) -- Join at positions which are changing following extmarks for i = 1, n_pos do local cur_pad_string = (i == 1 or i == n_pos) and '' or ' ' H.join_at_extmark(input_extmarks[i], cur_pad_string) end -- Put cursor back on tracked position H.put_cursor_at_extmark(cursor_extmark) -- Reconstruct input positions local res = vim.tbl_map(H.get_extmark_pos, input_extmarks) vim.api.nvim_buf_clear_namespace(0, H.ns_id, 0, -1) return res end --- Get previous visual region --- --- Get previous visual selection using |`<| and |`>| marks in the format of --- region (see |MiniSplitjoin-glossary|). Used in Visual mode mappings. --- --- Note: --- - Both marks are included in region, so for better --- - In linewise Visual mode --- ---@return table A region. See |MiniSplitjoin-glossary| for exact structure. MiniSplitjoin.get_visual_region = function() local from_pos, to_pos = vim.fn.getpos("'<"), vim.fn.getpos("'>") local from, to = { line = from_pos[2], col = from_pos[3] }, { line = to_pos[2], col = to_pos[3] } -- Tweak for linewise Visual selection if vim.fn.visualmode() == 'V' then from.col, to.col = 1, vim.fn.col({ to.line, '$' }) - 1 end return { from = from, to = to } end --- Get string's indent part --- ---@param line string String for which to compute indent. ---@param respect_comments boolean|nil Whether to respect comments as indent part. --- Default: `true`. --- ---@return string Part of input representing line's indent. Can be empty string. --- Use `string.len()` to compute indent in bytes. MiniSplitjoin.get_indent_part = function(line, respect_comments) if respect_comments == nil then respect_comments = true end if not respect_comments then return line:match('^%s*') end -- Make it respect various comment leaders local comment_indent = H.get_comment_indent(line, H.get_comment_leaders()) if comment_indent ~= '' then return comment_indent end return line:match('^%s*') end --- Operator for Normal mode mappings --- --- Main function to be used in expression mappings. No need to use it --- directly, everything is setup in |MiniSplitjoin.setup()|. --- ---@param task string Name of task. MiniSplitjoin.operator = function(task) local is_init_call = task == 'toggle' or task == 'split' or task == 'join' if not is_init_call then MiniSplitjoin[H.cache.operator_task]() return '' end if H.is_disabled() then -- Using `` prevents moving cursor caused by current implementation -- detail of adding `' '` inside expression mapping return [[\]] end H.cache.operator_task = task vim.o.operatorfunc = 'v:lua.MiniSplitjoin.operator' return 'g@' end -- Helper data ================================================================ -- Module default config H.default_config = vim.deepcopy(MiniSplitjoin.config) H.ns_id = vim.api.nvim_create_namespace('MiniSplitjoin') H.cache = { operator_task = nil } -- Helper functionality ======================================================= -- Settings ------------------------------------------------------------------- H.setup_config = function(config) -- General idea: if some table elements are not present in user-supplied -- `config`, take them from default config vim.validate({ config = { config, 'table', true } }) config = vim.tbl_deep_extend('force', vim.deepcopy(H.default_config), config or {}) vim.validate({ mappings = { config.mappings, 'table' }, detect = { config.detect, 'table' }, split = { config.split, 'table' }, join = { config.join, 'table' }, }) vim.validate({ ['mappings.toggle'] = { config.mappings.toggle, 'string', true }, ['mappings.split'] = { config.mappings.split, 'string' }, ['mappings.join'] = { config.mappings.join, 'string', true }, ['detect.brackets'] = { config.detect.brackets, 'table', true }, ['detect.separator'] = { config.detect.separator, 'string' }, ['detect.exclude_regions'] = { config.detect.exclude_regions, 'table', true }, ['split.hooks_pre'] = { config.split.hooks_pre, 'table' }, ['split.hooks_post'] = { config.split.hooks_post, 'table' }, ['join.hooks_pre'] = { config.join.hooks_pre, 'table' }, ['join.hooks_post'] = { config.join.hooks_post, 'table' }, }) return config end --stylua: ignore H.apply_config = function(config) MiniSplitjoin.config = config -- Make mappings local maps = config.mappings H.map('n', maps.toggle, 'v:lua.MiniSplitjoin.operator("toggle") . " "', { expr = true, desc = 'Toggle arguments' }) H.map('n', maps.split, 'v:lua.MiniSplitjoin.operator("split") . " "', { expr = true, desc = 'Split arguments' }) H.map('n', maps.join, 'v:lua.MiniSplitjoin.operator("join") . " "', { expr = true, desc = 'Join arguments' }) H.map('x', maps.toggle, ':lua MiniSplitjoin.toggle({ region = MiniSplitjoin.get_visual_region() })', { desc = 'Toggle arguments' }) H.map('x', maps.split, ':lua MiniSplitjoin.split({ region = MiniSplitjoin.get_visual_region() })', { desc = 'Split arguments' }) H.map('x', maps.join, ':lua MiniSplitjoin.join({ region = MiniSplitjoin.get_visual_region() })', { desc = 'Join arguments' }) end H.is_disabled = function() return vim.g.minisplitjoin_disable == true or vim.b.minisplitjoin_disable == true end H.get_config = function(config) return vim.tbl_deep_extend('force', MiniSplitjoin.config, vim.b.minisplitjoin_config or {}, config or {}) end H.get_opts = function(opts) opts = opts or {} -- Infer detect options. Can't use usual `vim.tbl_deep_extend()` because it -- doesn't work properly on arrays local default_detect = { brackets = { '%b()', '%b[]', '%b{}' }, separator = ',', exclude_regions = { '%b()', '%b[]', '%b{}', '%b""', "%b''" }, } local config = H.get_config() return { position = opts.position or H.get_cursor_pos(), region = opts.region, -- Extend `detect` not deeply to avoid unwanted values from longer defaults detect = vim.tbl_extend('force', default_detect, config.detect, opts.detect or {}), split = vim.tbl_deep_extend('force', config.split, opts.split or {}), join = vim.tbl_deep_extend('force', config.join, opts.join or {}), } end -- Split ---------------------------------------------------------------------- H.split_at_extmark = function(extmark_id) local pos = H.get_extmark_pos(extmark_id) -- Split H.set_text(pos.line - 1, pos.col, pos.line - 1, pos.col, { '', '' }) -- Remove trailing whitespace on split line local split_line = vim.fn.getline(pos.line) local start_of_trailspace = split_line:find('%s*$') H.set_text(pos.line - 1, start_of_trailspace - 1, pos.line - 1, split_line:len(), {}) -- Adjust indent on new line local cur_indent = MiniSplitjoin.get_indent_part(vim.fn.getline(pos.line + 1)) local new_indent = MiniSplitjoin.get_indent_part(split_line) H.set_text(pos.line, 0, pos.line, cur_indent:len(), { new_indent }) end H.find_split_positions = function(region, separator, exclude_regions) local sep_positions = H.find_separator_positions(region, separator, exclude_regions) local n_pos = #sep_positions sep_positions[n_pos].col = sep_positions[n_pos].col - 1 return sep_positions end -- Join ----------------------------------------------------------------------- H.join_at_extmark = function(extmark_id, pad) local line_num = H.get_extmark_pos(extmark_id).line if vim.api.nvim_buf_line_count(0) <= line_num then return end -- Join by replacing trailing whitespace of current line and indent of next -- one with `pad` local lines = vim.api.nvim_buf_get_lines(0, line_num - 1, line_num + 1, true) local above_start_col = lines[1]:len() - lines[1]:match('%s*$'):len() local below_end_col = MiniSplitjoin.get_indent_part(lines[2]):len() H.set_text(line_num - 1, above_start_col, line_num, below_end_col, { pad }) end H.find_join_positions = function(region, separator, exclude_regions) local lines = vim.api.nvim_buf_get_lines(0, region.from.line - 1, region.to.line, true) -- Join whole region into single line local res = {} local init_line = region.from.line - 1 for i = 1, #lines - 1 do table.insert(res, { line = init_line + i, col = lines[i]:len() }) end return res end -- Detect --------------------------------------------------------------------- H.find_smallest_bracket_region = function(position, brackets) local neigh = H.get_neighborhood() local cur_offset = neigh.pos_to_offset(position) local best_span = H.find_smallest_covering(neigh['1d'], cur_offset, brackets) if best_span == nil then return nil end return neigh.span_to_region(best_span) end H.find_smallest_covering = function(line, ref_offset, patterns) local res, min_width = nil, math.huge for _, pattern in ipairs(patterns) do local cur_init = 0 local left, right = string.find(line, pattern, cur_init) while left do if left <= ref_offset and ref_offset <= right and (right - left) < min_width then res, min_width = { from = left, to = right }, right - left end cur_init = left + 1 left, right = string.find(line, pattern, cur_init) end end return res end H.find_separator_positions = function(region, separator, exclude_regions) if separator == '' then return { region.from, region.to } end local neigh = H.get_neighborhood() local region_span = neigh.region_to_span(region) local region_s = neigh['1d']:sub(region_span.from, region_span.to) -- Match separator endings local seps = {} region_s:gsub(separator .. '()', function(r) table.insert(seps, r - 1) end) -- Remove separators that are in excluded regions. local inner_string, forbidden = region_s:sub(2, -2), {} local add_to_forbidden = function(l, r) table.insert(forbidden, { from = l + 1, to = r }) end for _, pat in ipairs(exclude_regions) do inner_string:gsub('()' .. pat .. '()', add_to_forbidden) end -- - Also exclude trailing separator inner_string:gsub('()' .. separator .. '%s*()$', add_to_forbidden) local sub_offsets = vim.tbl_filter(function(x) return not H.is_offset_inside_spans(x, forbidden) end, seps) -- Treat enclosing brackets as separators if region_s:len() > 2 then -- Use only last bracket in case of empty brackets table.insert(sub_offsets, 1, 1) end table.insert(sub_offsets, region_s:len()) -- Convert offsets to positions local start_offset = region_span.from return vim.tbl_map(function(sub_off) return neigh.offset_to_pos(start_offset + sub_off - 1) end, sub_offsets) end H.is_offset_inside_spans = function(ref_point, spans) for _, span in ipairs(spans) do if span.from <= ref_point and ref_point <= span.to then return true end end return false end H.is_positions_inside_brackets = function(from_pos, to_pos, brackets) local text_lines = vim.api.nvim_buf_get_text(0, from_pos.line - 1, from_pos.col - 1, to_pos.line - 1, to_pos.col, {}) local text = table.concat(text_lines, '\n') for _, b in ipairs(brackets) do if text:find('^' .. b .. '$') ~= nil then return true end end return false end H.is_char_at_position = function(position, char) local present_char = vim.fn.getline(position.line):sub(position.col, position.col) return present_char == char end -- Simplified version of "neighborhood" from 'mini.ai': -- - Use whol buffer. -- - No empty regions or spans. -- -- NOTEs: -- - `region = { from = { line = a, col = b }, to = { line = c, col = d } }`. -- End-inclusive charwise selection. All `a`, `b`, `c`, `d` are 1-indexed. -- - `offset` is the number between 1 to `neigh1d:len()`. H.get_neighborhood = function() local neigh2d = vim.api.nvim_buf_get_lines(0, 0, -1, false) -- Append 'newline' character to distinguish between lines in 1d case -- (crucial for handling empty lines) for k, v in pairs(neigh2d) do neigh2d[k] = v .. '\n' end local neigh1d = table.concat(neigh2d, '') local n_lines = #neigh2d -- Compute offsets for just before line starts local line_offsets = {} local cur_offset = 0 for i = 1, n_lines do line_offsets[i] = cur_offset cur_offset = cur_offset + neigh2d[i]:len() end -- Convert 2d buffer position to 1d offset local pos_to_offset = function(pos) return line_offsets[pos.line] + pos.col end -- Convert 1d offset to 2d buffer position local offset_to_pos = function(offset) for i = 1, n_lines - 1 do if line_offsets[i] < offset and offset <= line_offsets[i + 1] then return { line = i, col = offset - line_offsets[i] } end end return { line = n_lines, col = offset - line_offsets[n_lines] } end -- Convert 2d region to 1d span local region_to_span = function(region) return { from = pos_to_offset(region.from), to = pos_to_offset(region.to) } end -- Convert 1d span to 2d region local span_to_region = function(span) return { from = offset_to_pos(span.from), to = offset_to_pos(span.to) } end return { ['1d'] = neigh1d, ['2d'] = neigh2d, pos_to_offset = pos_to_offset, offset_to_pos = offset_to_pos, region_to_span = region_to_span, span_to_region = span_to_region, } end -- Extmarks ------------------------------------------------------------------- H.put_extmark_at_positions = function(positions) return vim.tbl_map( function(pos) return vim.api.nvim_buf_set_extmark(0, H.ns_id, pos.line - 1, pos.col - 1, {}) end, positions ) end H.get_extmark_pos = function(extmark_id) local res = vim.api.nvim_buf_get_extmark_by_id(0, H.ns_id, extmark_id, {}) return { line = res[1] + 1, col = res[2] + 1 } end H.get_cursor_pos = function() local cur_pos = vim.api.nvim_win_get_cursor(0) return { line = cur_pos[1], col = cur_pos[2] + 1 } end H.put_cursor_at_extmark = function(id) local new_pos = vim.api.nvim_buf_get_extmark_by_id(0, H.ns_id, id, {}) vim.api.nvim_win_set_cursor(0, { new_pos[1] + 1, new_pos[2] }) vim.api.nvim_buf_del_extmark(0, H.ns_id, id) end -- Indent --------------------------------------------------------------------- H.increase_indent = function(from_line, to_line) local lines = vim.api.nvim_buf_get_lines(0, from_line - 1, to_line, true) -- Respect comment leaders only if all lines are commented local comment_leaders = H.get_comment_leaders() local respect_comments = H.is_comment_block(lines, comment_leaders) -- Increase indent of all lines (end-inclusive) local pad = vim.bo.expandtab and string.rep(' ', vim.fn.shiftwidth()) or '\t' for i, l in ipairs(lines) do local n_indent = MiniSplitjoin.get_indent_part(l, respect_comments):len() -- Don't increase indent of blank lines (possibly respecting comments) local cur_by_string = l:len() == n_indent and '' or pad local line_num = from_line + i - 1 H.set_text(line_num - 1, n_indent, line_num - 1, n_indent, { cur_by_string }) end end H.get_comment_indent = function(line, comment_leaders) local res = '' for _, leader in ipairs(comment_leaders) do local cur_match = line:match('^%s*' .. vim.pesc(leader) .. '%s*') -- Use biggest match in case of several matches. Allows respecting "nested" -- comment leaders like "---" and "--". if type(cur_match) == 'string' and res:len() < cur_match:len() then res = cur_match end end return res end -- Comments ------------------------------------------------------------------- H.get_comment_leaders = function() local res = {} -- From 'commentstring' local main_leader = vim.split(vim.bo.commentstring, '%%s')[1] -- - Ensure there is no whitespace before or after table.insert(res, vim.trim(main_leader)) -- From 'comments' for _, comment_part in ipairs(vim.opt_local.comments:get()) do local prefix, suffix = comment_part:match('^(.*):(.*)$') -- Control whitespace around suffix suffix = vim.trim(suffix) if prefix:find('b') then -- Respect `b` flag (for blank) requiring space, tab or EOL after it table.insert(res, suffix .. ' ') table.insert(res, suffix .. '\t') elseif prefix:find('f') == nil then -- Add otherwise ignoring `f` flag (only first line should have it) table.insert(res, suffix) end end return res end H.is_comment_block = function(lines, comment_leaders) for _, l in ipairs(lines) do if not H.is_commented(l, comment_leaders) then return false end end return true end H.is_commented = function(line, comment_leaders) for _, leader in ipairs(comment_leaders) do if line:find('^%s*' .. vim.pesc(leader) .. '%s*') ~= nil then return true end end return false end -- Utilities ------------------------------------------------------------------ H.error = function(msg) error(string.format('(mini.splitjoin) %s', msg), 0) end H.map = function(mode, lhs, rhs, opts) if lhs == '' then return end opts = vim.tbl_deep_extend('force', { silent = true }, opts or {}) vim.keymap.set(mode, lhs, rhs, opts) end H.set_text = function(start_row, start_col, end_row, end_col, replacement) local ok = pcall(vim.api.nvim_buf_set_text, 0, start_row, start_col, end_row, end_col, replacement) if not ok or #replacement == 0 then return end -- Fix cursor position if it was exactly on start position. -- See https://github.com/neovim/neovim/issues/22526. local cursor = vim.api.nvim_win_get_cursor(0) if (start_row + 1) == cursor[1] and start_col == cursor[2] then vim.api.nvim_win_set_cursor(0, { cursor[1], cursor[2] + replacement[1]:len() }) end end return MiniSplitjoin