8000 mini.nvim/lua/mini/align.lua at main · Groctel/mini.nvim · GitHub
[go: up one dir, main page]

Skip to content

Latest commit

 

History

History
2073 lines (1842 loc) · 75.2 KB

align.lua

File metadata and controls

2073 lines (1842 loc) · 75.2 KB
_G.MiniAlign = MiniAlign
-- 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 # Options ~
---
--- ## Modifiers ~
---
--- `MiniAlign.config.modifiers` is used to define interactive user experience
--- of managing alignment process. It is a table with single character keys and
--- modifier function values.
---
--- Each modifier function:
--- - Is called when corresponding modifier key is pressed.
--- - Has signature `(steps, opts)` and should modify any of its input in place.
---
--- Examples:
--- - Modifier function used for default 'i' modifier: >lua
---
--- function(steps, _)
--- table.insert(steps.pre_split, MiniAlign.gen_step.ignore_split())
--- end
--- <
--- - Tweak 't' modifier to use highest indentation instead of keeping it: >lua
---
--- require('mini.align').setup({
--- modifiers = {
--- t = function(steps, _)
--- local trim_high = MiniAlign.gen_step.trim('both', 'high')
--- table.insert(steps.pre_justify, trim_high)
--- end
--- }
--- })
--- <
--- - Tweak `j` modifier to cycle through available "justify_side" option
--- values (like in 'junegunn/vim-easy-align'): >lua
---
--- require('mini.align').setup({
--- modifiers = {
--- j = function(_, opts)
--- local next_option = ({
--- left = 'center', center = 'right', right = 'none', none = 'left',
--- })[opts.justify_side]
--- opts.justify_side = next_option or 'left'
--- end,
--- },
--- })
--- <
--- ## Options ~
---
--- `MiniAlign.config.options` defines default values of options used to control
--- behavior of steps.
---
--- Examples:
--- - Set `justify_side = 'center'` to center align at initialization.
---
--- For more details about options see |MiniAlign.align_strings()| and entries of
--- |MiniAlign.gen_step| for default main steps.
---
--- ## Steps ~
---
--- `MiniAlign.config.steps` defines default steps to be applied during
--- alignment process.
---
--- Examples:
--- - Align by default only first pair of columns: >lua
---
--- local align = require('mini.align')
--- align.setup({
--- steps = {
--- pre_justify = { align.gen_step.filter('n == 1') }
--- },
--- })
--- <
MiniAlign.config = {
-- Module mappings. Use `''` (empty string) to disable one.
mappings = {
start = 'ga',
start_with_preview = 'gA',
},
-- Modifiers changing alignment steps and/or options
modifiers = {
-- Main option modifiers
--minidoc_replace_start ['s'] = --<function: enter split pattern>,
['s'] = function(_, opts)
local input = H.user_input('Enter split Lua pattern')
if input == nil then return end
opts.split_pattern = input
end,
--minidoc_replace_end
--minidoc_replace_start ['j'] = --<function: choose justify side>,
['j'] = function(_, opts)
-- stylua: ignore
H.echo({
{ 'Select justify: ', 'ModeMsg' }, { 'l', 'Question' }, { 'eft, ' },
{ 'c', 'Question' }, { 'enter, ' }, { 'r', 'Question' }, { 'ight, ' },
{ 'n', 'Question' }, { 'one' }
})
local ok, char = pcall(vim.fn.getcharstr)
if not ok or char == '\27' then return end
local direction = ({ l = 'left', c = 'center', r = 'right', n = 'none' })[char]
if direction == nil then return end
opts.justify_side = direction
end,
--minidoc_replace_end
--minidoc_replace_start ['m'] = --<function: enter merge delimiter>,
['m'] = function(_, opts)
local input = H.user_input('Enter merge delimiter')
if input == nil then return end
opts.merge_delimiter = input
end,
--minidoc_replace_end
-- Modifiers adding pre-steps
--minidoc_replace_start ['f'] = --<function: filter parts by entering Lua expression>,
['f'] = function(steps, _)
local input = H.user_input('Enter filter expression')
local step = MiniAlign.gen_step.filter(input)
if step == nil then return end
table.insert(steps.pre_justify, step)
end,
--minidoc_replace_end
--minidoc_replace_start ['i'] = --<function: ignore some split matches>,
['i'] = function(steps, _) table.insert(steps.pre_split, MiniAlign.gen_step.ignore_split()) end,
--minidoc_replace_end
--minidoc_replace_start ['p'] = --<function: pair parts>,
['p'] = function(steps, _) table.insert(steps.pre_justify, MiniAlign.gen_step.pair()) end,
--minidoc_replace_end
--minidoc_replace_start ['t'] = --<function: trim parts>,
['t'] = function(steps, _) table.insert(steps.pre_justify, MiniAlign.gen_step.trim()) end,
--minidoc_replace_end
-- Delete some last pre-step
--minidoc_replace_start ['<BS>'] = --<function: delete some last pre-step>,
[vim.api.nvim_replace_termcodes('<BS>', true, true, true)] = function(steps, _)
local has_pre = {}
for _, pre in ipairs({ 'pre_split', 'pre_justify', 'pre_merge' }) do
if #steps[pre] > 0 then table.insert(has_pre, pre) end
end
if #has_pre == 0 then return end
if #has_pre == 1 then
local pre = steps[has_pre[1]]
table.remove(pre, #pre)
return
end
--stylua: ignore
H.echo({
{ 'Select pre-step to remove: ', 'ModeMsg' }, { 's', 'Question' }, { 'plit, ' },
{ 'j', 'Question' }, { 'ustify, ' }, { 'm', 'Question' }, { 'erge' },
})
local ok, char = pcall(vim.fn.getcharstr)
if not ok or char == '\27' then return end
if char == 's' then table.remove(steps.pre_split, #steps.pre_split) end
if char == 'j' then table.remove(steps.pre_justify, #steps.pre_justify) end
if char == 'm' then table.remove(steps.pre_merge, #steps.pre_merge) end
end,
--minidoc_replace_end
-- Special configurations for common splits
--minidoc_replace_start ['='] = --<function: enhanced setup for '='>,
['='] = function(steps, opts)
opts.split_pattern = '%p*=+[<>~]*'
table.insert(steps.pre_justify, MiniAlign.gen_step.trim())
opts.merge_delimiter = ' '
end,
--minidoc_replace_end
--minidoc_replace_start [','] = --<function: enhanced setup for ','>,
[','] = function(steps, opts)
opts.split_pattern = ','
table.insert(steps.pre_justify, MiniAlign.gen_step.trim())
table.insert(steps.pre_justify, MiniAlign.gen_step.pair())
opts.merge_delimiter = ' '
end,
--minidoc_replace_end
--minidoc_replace_start ['|'] = --<function: enhanced setup for '|'>,
['|'] = function(steps, opts)
opts.split_pattern = '|'
table.insert(steps.pre_justify, MiniAlign.gen_step.trim())
opts.merge_delimiter = ' '
end,
--minidoc_replace_end
--minidoc_replace_start [' '] = --<function: enhanced setup for ' '>,
[' '] = function(steps, opts)
table.insert(
steps.pre_split,
MiniAlign.new_step('squash', function(strings)
-- Replace all space sequences with single space (except indent)
for i, s in ipairs(strings) do
strings[i] = s:gsub('()(%s+)', function(n, space) return n == 1 and space or ' ' end)
end
end)
)
-- Don't use `' '` to respect indent
opts.split_pattern = '%s+'
end,
--minidoc_replace_end
},
-- Default options controlling alignment process
options = {
split_pattern = '',
justify_side = 'left',
merge_delimiter = '',
},
-- Default steps performing alignment (if `nil`, default is used)
steps = {
pre_split = {},
split = nil,
pre_justify = {},
justify = nil,
pre_merge = {},
merge = nil,
},
-- Whether to disable showing non-error feedback
-- This also affects (purely informational) helper messages shown after
-- idle time if user input is required.
silent = false,
}
--minidoc_afterlines_end
-- Module functionality =======================================================
--- Align strings
---
--- For details about alignment process see |MiniAlign-algorithm|.
---
---@param strings table Array of strings.
---@param opts table|nil Options. Its copy will be passed to steps as second
--- argument. Extended with `MiniAlign.config.options`.
--- This is a place to control default main steps:
--- - `opts.split_pattern` - Lua pattern(s) used to make split parts.
--- - `opts.split_exclude_patterns` - which split matches should be ignored.
--- - `opts.justify_side` - which direction(s) alignment should be done.
--- - `opts.justify_offsets` - offsets tweaking width of first column
--- - `opts.merge_delimiter` - which delimiter(s) to use when merging.
--- For more information see |MiniAlign.gen_step| entry for corresponding
--- default step.
---@param steps table|nil Steps. Extended with `MiniAlign.config.steps`.
--- Possible `nil` values are replaced with corresponding default steps:
--- - `split` - |MiniAlign.gen_step.default_split()|.
--- - `justify` - |MiniAlign.gen_step.default_justify()|.
--- - `merge` - |MiniAlign.gen_step.default_merge()|.
MiniAlign.align_strings = function(strings, opts, steps)
-- Validate arguments
if not H. 10BC8 is_array_of(strings, H.is_string) then
H.error('First argument of `MiniAlign.align_strings()` should be array of strings.')
end
opts = H.normalize_opts(opts)
steps = H.normalize_steps(steps, 'steps')
-- Make a copy so that modification in place doesn't affect input
strings = vim.deepcopy(strings)
-- Pre split
for _, step in ipairs(steps.pre_split) do
H.apply_step(step, strings, opts, 'pre_split')
end
-- Split
local parts = H.apply_step(steps.split, strings, opts, 'split')
if not H.is_parts(parts) then
if H.can_be_parts(parts) then
parts = MiniAlign.as_parts(parts)
else
H.error('Output of `split` step should be convertible to parts. See `:h MiniAlign.as_parts()`.')
end
end
-- Pre justify
for _, step in ipairs(steps.pre_justify) do
H.apply_step(step, parts, opts, 'pre_justify')
end
-- Justify
H.apply_step(steps.justify, parts, opts, 'justify')
-- Pre merge
for _, step in ipairs(steps.pre_merge) do
H.apply_step(step, parts, opts, 'pre_merge')
end
-- Merge
local new_strings = H.apply_step(steps.merge, parts, opts, 'merge')
if not H.is_array_of(new_strings, H.is_string) then H.error('Output of `merge` step should be array of strings.') end
return new_strings
end
--- Align current region with user-supplied steps
---
--- Mostly designed to be used inside mappings.
---
--- Will use |MiniAlign.align_strings()| and set the following options in `opts`:
--- - `justify_offsets` - array of offsets used to achieve actual alignment of
--- a region. It is non-trivial (not array of zeros) only for charwise
--- selection: offset of first string is computed as width of prefix to the
--- left of region start.
--- - `region` - current affected region (see |MiniAlign-glossary|). Can be
--- used to create more advanced steps.
--- - `mode` - mode of selection (see |MiniAlign-glossary|).
---
---@param mode string Selection mode. One of "char", "line", "block".
MiniAlign.align_user = function(mode)
local modifiers = H.get_config().modifiers
local with_preview = H.cache.with_preview
local opts = H.cache.opts or H.normalize_opts()
local steps = H.cache.steps or H.normalize_steps()
local steps_are_from_cache = H.cache.steps ~= nil
H.cache.region = nil
-- Track if lines were actually changed to properly undo during preview
local lines_were_changed = false
-- Make initial process
lines_were_changed = H.process_current_region(lines_were_changed, mode, opts, steps)
-- Make early return:
-- - If cache is present (enables dot-repeat).
-- - If `split` is not default with no preview (no more information needed).
if steps_are_from_cache or (not with_preview and opts.split_pattern ~= '') then return end
-- Ask user to input modifier id until no more is needed
local n_iter = 0
while true do
-- Get modifier from user
local id = H.user_modifier(with_preview, H.make_status_msg_chunks(opts, steps))
n_iter = n_iter + 1
-- Stop in case user supplied inappropriate modifier id (abort)
-- Also stop in case of too many iterations (guard from infinite cycle)
if id == nil or n_iter > 1000 then
if lines_were_changed then H.undo() end
if n_iter > 1000 then H.echo({ { 'Too many modifiers typed.', 'WarningMsg' } }, true) end
break
end
-- Stop preview after `<CR>` (confirmation)
if with_preview and id == '\r' then break end
-- Apply modifier
local mod = modifiers[id]
if mod == nil then
-- Use supplied identifier as split pattern
opts.split_pattern = vim.pesc(id)
else
-- Modifier should change input `steps` table in place
local ok, out = pcall(mod, steps, opts)
if not ok then
-- Force message to appear for 500ms because it might be overridden by
-- helper status message
local msg = string.format('Modifier %s should be properly callable. Reason: %s', vim.inspect(id), out)
H.echo({ { msg, 'WarningMsg' } }, true)
vim.cmd('redraw')
vim.loop.sleep(500)
end
end
-- Normalize steps and options while validating their correctness
opts = H.normalize_opts(opts)
steps = H.normalize_steps(steps, opts)
-- Process region while tracking if lines were set at least once
local lines_now_changed = H.process_current_region(lines_were_changed, mode, opts, steps)
lines_were_changed = lines_were_changed or lines_now_changed
-- Stop in "no preview" mode right after `split` is defined
if not with_preview and opts.split_pattern ~= '' then break end
end
-- Remove helper status message (if shown)
H.unecho()
end
--- Convert 2d array of strings to parts
---
--- This function verifies if input is a proper 2d array of strings and adds
--- methods to its copy.
---
---@class parts
---
---@field apply function Takes callable `f` and applies it to every part.
--- Callable should have signature `(s, data)`: `s` is a string part,
--- `data` - table with its data (<row> has row number, <col> has column number).
--- Returns new 2d array.
---
---@field apply_inplace function Takes callable `f` and applies it to every part.
--- Should have same signature as in `apply` method. Outputs (should all be
--- strings) are assigned in place to a corresponding parts element. Returns
--- parts itself to enable method chaining.
---
---@field get_dims function Return dimensions of parts array: a table with
--- <row> and <col> keys having number of rows and number of columns (maximum
--- number of elements across all rows).
---
---@field group function Concatenate neighboring strings based on supplied
--- boolean mask and direction (one of "left", default, or "right"). Has
--- signature `(mask, direction)` and modifies parts in place. Returns parts
--- itself to enable method chaining.
--- Example:
--- - Parts: { { "a", "b", "c" }, { "d", "e" }, { "f" } }
--- - Mask: { { false, false, true }, { true, false }, { false } }
--- - Result for direction "left": { { "abc" }, { "d", "e" }, { "f" } }
--- - Result for direction "right": { { "ab","c" }, { "de" }, { "f" } }
---
---@field pair function Concatenate neighboring element pairs. Takes
--- `direction` as input (one of "left", default, or "right") and applies
--- `group()` for an alternating mask.
--- Example:
--- - Parts: { { "a", "b", "c" }, { "d", "e" }, { "f" } }
--- - Result for direction "left": { { "ab", "c" }, { "de" }, { "f" } }
--- - Result for direction "right": { { "a", "bc" }, { "de" }, { "f" } }
---
---@field slice_col function Return column with input index `j`. Note: it might
--- not be an array if rows have unequal number of columns.
---
---@field slice_row function Return row with input index `i`.
---
---@field trim function Trim elements whitespace. Has signature `(direction, indent)`
--- and modifies parts in place. Returns parts itself to enable method chaining.
--- - Possible values of `direction`: "both" (default), "left", "right",
--- "none". Defines from which side whitespaces should be removed.
--- - Possible values of `indent`: "keep" (default), "low", "high", "remove".
--- Defines what to do with possible indent (left whitespace of first string
--- in a row). Value "keep" keeps it; "low" makes all indent equal to the
--- lowest across rows; "high" - highest across rows; "remove" - removes indent.
---
---@usage >lua
--- parts = MiniAlign.as_parts({ { 'a', 'b' }, { 'c' } })
--- print(vim.inspect(parts.get_dims())) -- Should be { row = 2, col = 2 }
---
--- parts.apply_inplace(function(s, data)
--- return ' ' .. data.row .. s .. data.col .. ' '
--- end)
--- print(vim.inspect(parts)) -- Should be { { ' 1a1 ', ' 1b2 ' }, { ' 2c1 ' } }
---
--- parts.trim('both', 'remove').pair()
--- print(vim.inspect(parts)) -- Should be { { '1a11b2' }, { '2c1' } }
--- <
MiniAlign.as_parts = function(arr2d)
local ok, msg = H.can_be_parts(arr2d)
if not ok then H.error('Input of `as_parts()` ' .. msg) end
local parts = vim.deepcopy(arr2d)
local methods = {}
methods.apply = function(f)
local res = {}
for i, row in ipairs(parts) do
res[i] = {}
for j, s in ipairs(row) do
res[i][j] = f(s, { row = i, col = j })
end
end
return res
end
methods.apply_inplace = function(f)
for i, row in ipairs(parts) do
for j, s in ipairs(row) do
local new_val = f(s, { row = i, col = j })
if type(new_val) ~= 'string' then H.error('Input of `apply_inplace()` method should always return string.') end
parts[i][j] = new_val
end
end
return parts
end
methods.get_dims = function()
local n_cols = 0
for _, row in ipairs(parts) do
n_cols = math.max(n_cols, #row)
end
return { row = #parts, col = n_cols }
end
-- Group cells into single string based on boolean mask.
-- Can be used for filtering separators and sticking separator to its part.
methods.group = function(mask, direction)
direction = direction or 'left'
for i, row in ipairs(parts) do
local group_tables = H.group_by_mask(row, mask[i], direction)
parts[i] = vim.tbl_map(table.concat, group_tables)
end
return parts
end
methods.pair = function(direction)
direction = direction or 'left'
local mask = {}
for i, row in ipairs(parts) do
mask[i] = {}
for j, _ in ipairs(row) do
-- Count from corresponding end
local num = direction == 'left' and j or (#row - j + 1)
mask[i][j] = num % 2 == 0
end
end
parts.group(mask, direction)
return parts
end
-- NOTE: output might not be an array (some rows can not have input column)
-- Use `vim.tbl_keys()` and `vim.tbl_values()`
methods.slice_col = function(j)
return vim.tbl_map(function(row) return row[j] end, parts)
end
methods.slice_row = function(i) return parts[i] or {} end
methods.trim = function(direction, indent)
direction = direction or 'both'
indent = indent or 'keep'
-- Verify arguments
local trim_fun = H.trim_functions[direction]
if not vim.is_callable(trim_fun) then
local allowed = vim.tbl_map(vim.inspect, vim.tbl_keys(H.trim_functions))
table.sort(allowed)
H.error('`direction` should be one of ' .. table.concat(allowed, ', ') .. '.')
end
local indent_fun = H.indent_functions[indent]
if not vim.is_callable(indent_fun) then
local allowed = vim.tbl_map(vim.inspect, vim.tbl_keys(H.indent_functions))
table.sort(allowed)
H.error('`indent` should be one of ' .. table.concat(allowed, ', ') .. '.')
end
-- Compute indentation to restore later
local row_indent = vim.tbl_map(function(row) return row[1]:match('^(%s*)') end, parts)
row_indent = indent_fun(row_indent)
-- Trim
parts.apply_inplace(trim_fun)
-- Restore indentation if it was removed
if vim.tbl_contains({ 'both', 'left' }, direction) then
for i, row in ipairs(parts) do
row[1] = string.format('%s%s', row_indent[i], row[1])
end
end
return parts
end
return setmetatable(parts, { class = 'parts', __index = methods })
end
0