dotfiles/.config/nvim/lua/mini/splitjoin.lua

1123 lines
41 KiB
Lua

--- *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 <line> and <col> containing line and column
--- numbers respectively. Both are 1-indexed. Example: `{ line = 2, col = 1 }`.
---
--- - REGION - table representing region in a buffer. Fields: <from> and
--- <to> 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:
--- - <position> `(table)` - position at which to find smallest bracket region.
--- See |MiniSplitjoin-glossary| for the structure.
--- Default: cursor position.
--- - <region> `(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 - <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:
--- - <pad> `(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:
--- - <sep> `(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:
--- - <sep> `(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 `<Esc>` prevents moving cursor caused by current implementation
-- detail of adding `' '` inside expression mapping
return [[\<Esc>]]
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, ':<C-u>lua MiniSplitjoin.toggle({ region = MiniSplitjoin.get_visual_region() })<CR>', { desc = 'Toggle arguments' })
H.map('x', maps.split, ':<C-u>lua MiniSplitjoin.split({ region = MiniSplitjoin.get_visual_region() })<CR>', { desc = 'Split arguments' })
H.map('x', maps.join, ':<C-u>lua MiniSplitjoin.join({ region = MiniSplitjoin.get_visual_region() })<CR>', { 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