local M = {} local _cache = nil local function parse_transforms(help_output) local transforms = {} local in_commands = false for line in help_output:gmatch('[^\n]+') do if line:match('^Available Commands:') then in_commands = true elseif in_commands then if not line:match('^%s') then break end local cmd = line:match('^%s+(%S+)') local desc = line:match('^%s+%S+%s+(.*%S)') or '' if cmd and cmd ~= 'help' and cmd ~= 'completion' then table.insert(transforms, { cmd = cmd, desc = desc }) end end end return transforms end function M.get_transforms() if _cache then return _cache end local help = vim.fn.system('sttr --help 2>&1') if not help:match('Available Commands') then vim.notify('[sttr] sttr not found in PATH', vim.log.levels.ERROR) return nil end _cache = parse_transforms(help) return _cache end function M.clear_cache() _cache = nil end -- Returns exclusive byte end of the char starting at byte `start_byte` (0-indexed) in `line` local function char_exclusive_end(line, start_byte) local ci = vim.fn.charidx(line, start_byte) local next_byte = vim.fn.byteidx(line, ci + 1) return next_byte >= 0 and next_byte or #line end local function get_visual_selection() local mode = vim.fn.visualmode() local start_pos = vim.fn.getpos("'<") local end_pos = vim.fn.getpos("'>") local sl = start_pos[2] local el = end_pos[2] -- getregion handles all visual modes correctly (requires Neovim 0.10+) local selected_lines = vim.fn.getregion(start_pos, end_pos, { type = mode }) local text = table.concat(selected_lines, '\n') if mode == 'V' then return text, { mode = 'V', sl = sl, el = el } elseif mode == 'v' then local sc = start_pos[3] - 1 -- 0-indexed local last_line = vim.api.nvim_buf_get_lines(0, el - 1, el, false)[1] or '' local ec = char_exclusive_end(last_line, math.min(end_pos[3] - 1, #last_line)) return text, { mode = 'v', sl = sl, sc = sc, el = el, ec = ec } else -- block visual '\22' local sc_vcol = vim.fn.virtcol("'<") local ec_vcol = vim.fn.virtcol("'>") local block_data = {} for lnum = sl, el do local line = vim.api.nvim_buf_get_lines(0, lnum - 1, lnum, false)[1] or '' local sc_v2c = vim.fn.virtcol2col(0, lnum, sc_vcol) -- Line too short to reach the block start: empty selection at end of line if sc_v2c <= 0 then table.insert(block_data, { lnum = lnum, sc = #line, ec = #line }) else local sc_byte = sc_v2c - 1 local ec_v2c = vim.fn.virtcol2col(0, lnum, ec_vcol) local ec_byte = ec_v2c > 0 and char_exclusive_end(line, math.min(ec_v2c - 1, #line)) or #line table.insert(block_data, { lnum = lnum, sc = sc_byte, ec = ec_byte }) end end return text, { mode = '\22', block_data = block_data, selected_lines = selected_lines } end end local function apply(sel, result) if sel.mode == 'V' then vim.api.nvim_buf_set_lines(0, sel.sl - 1, sel.el, false, vim.split(result, '\n', { plain = true })) elseif sel.mode == 'v' then vim.api.nvim_buf_set_text(0, sel.sl - 1, sel.sc, sel.el - 1, sel.ec, vim.split(result, '\n', { plain = true })) else -- block: result is a list of per-line strings -- Reverse order so line numbers stay valid during replacement for i = #sel.block_data, 1, -1 do local bd = sel.block_data[i] local line = vim.api.nvim_buf_get_lines(0, bd.lnum - 1, bd.lnum, false)[1] or '' -- sc is 0-indexed inclusive start, ec is 0-indexed exclusive end -- Lua string.sub is 1-indexed inclusive on both ends local before = line:sub(1, bd.sc) local after = line:sub(bd.ec + 1) vim.api.nvim_buf_set_lines(0, bd.lnum - 1, bd.lnum, false, { before .. (result[i] or '') .. after }) end end end function M.run() local transforms = M.get_transforms() if not transforms or #transforms == 0 then return end local text, sel = get_visual_selection() if not text then return end require('sttr.picker').pick(transforms, function(item) if not item then return end if sel.mode == '\22' then -- Block visual: transform each line independently local result = {} for _, bd in ipairs(sel.block_data) do local line = vim.api.nvim_buf_get_lines(0, bd.lnum - 1, bd.lnum, false)[1] or '' local portion = line:sub(bd.sc + 1, bd.ec) local r = vim.fn.system({ 'sttr', item.cmd }, portion) if vim.v.shell_error ~= 0 then vim.notify('[sttr] Error: ' .. r, vim.log.levels.ERROR) return end table.insert(result, r:gsub('\n$', '')) end apply(sel, result) else local result = vim.fn.system({ 'sttr', item.cmd }, text) if vim.v.shell_error ~= 0 then vim.notify('[sttr] Error: ' .. result, vim.log.levels.ERROR) return end apply(sel, result:gsub('\n$', '')) end end) end return M