mirror of
https://github.com/anotherhadi/sttr.nvim.git
synced 2026-10-05 08:48:25 +02:00
init
This commit is contained in:
@@ -0,0 +1,66 @@
|
|||||||
|
# sttr.nvim
|
||||||
|
|
||||||
|
A Neovim plugin for [sttr](https://github.com/abhimanyu003/sttr): select text, pick a transformation, done.
|
||||||
|
|
||||||
|
Supports charwise (`v`), linewise (`V`), and block (`<C-v>`) visual selections.
|
||||||
|
The list of available transformations is read dynamically from your installed version of sttr.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Neovim 0.10+
|
||||||
|
- [sttr](https://github.com/abhimanyu003/sttr) installed and available in `$PATH`
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
**lazy.nvim**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
{
|
||||||
|
'anotherhadi/sttr.nvim',
|
||||||
|
config = function()
|
||||||
|
require('sttr').setup()
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```lua
|
||||||
|
require('sttr').setup()
|
||||||
|
```
|
||||||
|
|
||||||
|
No keymaps are set by default. Add your own:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
vim.keymap.set('v', '<leader>st', ':<C-u>SttrTransform<CR>', { silent = true, desc = 'sttr: transform selection' })
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
| ----------------- | --------------------------------------------------------------- |
|
||||||
|
| `:SttrTransform` | Open the transformation picker for the current visual selection |
|
||||||
|
| `:SttrClearCache` | Clear the cached transformation list (re-reads `sttr --help`) |
|
||||||
|
|
||||||
|
## Pickers
|
||||||
|
|
||||||
|
The plugin automatically uses the best available picker:
|
||||||
|
|
||||||
|
1. [telescope.nvim](https://github.com/nvim-telescope/telescope.nvim)
|
||||||
|
2. [snacks.nvim](https://github.com/folke/snacks.nvim) picker
|
||||||
|
3. `vim.ui.select` (built-in, or overridden by snacks/dressing/etc.)
|
||||||
|
|
||||||
|
## Block visual
|
||||||
|
|
||||||
|
In block visual mode (`<C-v>`), each line of the selection is transformed independently. This is useful for transforming a column of values (e.g. base64-decoding a column of tokens).
|
||||||
|
|
||||||
|
## Transformations
|
||||||
|
|
||||||
|
sttr.nvim dynamically reads transformations from `sttr --help` at first use, so it always reflects your installed version. sttr supports 60+ operations including:
|
||||||
|
|
||||||
|
- Encoding: base64, base32, URL, HTML, hex, morse…
|
||||||
|
- Hashing: md5, sha256, sha512, bcrypt…
|
||||||
|
- Case: camelCase, snake_case, kebab-case, UPPER, lower…
|
||||||
|
- Lines: sort, reverse, unique, shuffle…
|
||||||
|
- Formats: JSON↔YAML, markdown→HTML…
|
||||||
|
- Extraction: emails, IPs, URLs…
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.setup()
|
||||||
|
vim.api.nvim_create_user_command("SttrTransform", function()
|
||||||
|
require("sttr.transform").run()
|
||||||
|
end, { desc = "sttr: transform visual selection" })
|
||||||
|
|
||||||
|
vim.api.nvim_create_user_command("SttrClearCache", function()
|
||||||
|
require("sttr.transform").clear_cache()
|
||||||
|
vim.notify("[sttr] cache cleared", vim.log.levels.INFO)
|
||||||
|
end, { desc = "sttr: clear transformations cache" })
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
local function fmt(item)
|
||||||
|
return string.format('%-28s %s', item.cmd, item.desc)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function with_telescope(items, on_choice)
|
||||||
|
local pickers = require('telescope.pickers')
|
||||||
|
local finders = require('telescope.finders')
|
||||||
|
local conf = require('telescope.config').values
|
||||||
|
local actions = require('telescope.actions')
|
||||||
|
local action_state = require('telescope.actions.state')
|
||||||
|
|
||||||
|
pickers.new({}, {
|
||||||
|
prompt_title = 'sttr',
|
||||||
|
finder = finders.new_table({
|
||||||
|
results = items,
|
||||||
|
entry_maker = function(item)
|
||||||
|
local display = fmt(item)
|
||||||
|
return { value = item, display = display, ordinal = display }
|
||||||
|
end,
|
||||||
|
}),
|
||||||
|
sorter = conf.generic_sorter({}),
|
||||||
|
attach_mappings = function(prompt_bufnr)
|
||||||
|
actions.select_default:replace(function()
|
||||||
|
actions.close(prompt_bufnr)
|
||||||
|
local entry = action_state.get_selected_entry()
|
||||||
|
on_choice(entry and entry.value)
|
||||||
|
end)
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
}):find()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function with_snacks(snacks, items, on_choice)
|
||||||
|
snacks.picker.select(items, {
|
||||||
|
prompt = 'sttr',
|
||||||
|
format_item = fmt,
|
||||||
|
}, on_choice)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function with_native(items, on_choice)
|
||||||
|
vim.ui.select(items, {
|
||||||
|
prompt = 'sttr',
|
||||||
|
format_item = fmt,
|
||||||
|
}, on_choice)
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.pick(items, on_choice)
|
||||||
|
if pcall(require, 'telescope') then
|
||||||
|
return with_telescope(items, on_choice)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Explicit snacks check: covers users who have picker but not select override
|
||||||
|
local ok, snacks = pcall(require, 'snacks')
|
||||||
|
if ok and type(snacks.picker) == 'table' and type(snacks.picker.select) == 'function' then
|
||||||
|
return with_snacks(snacks, items, on_choice)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- vim.ui.select: snacks may have overridden it via { select = { enabled = true } }
|
||||||
|
with_native(items, on_choice)
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
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
|
||||||
Reference in New Issue
Block a user