commit 2a0d03c2584b6a6c9c3d10905ebf5860eb7c6e5e Author: Hadi <112569860+anotherhadi@users.noreply.github.com> Date: Thu May 21 13:56:31 2026 +0200 init diff --git a/README.md b/README.md new file mode 100644 index 0000000..54a2884 --- /dev/null +++ b/README.md @@ -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 (``) 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', 'st', ':SttrTransform', { 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 (``), 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… diff --git a/lua/sttr/init.lua b/lua/sttr/init.lua new file mode 100644 index 0000000..eddebac --- /dev/null +++ b/lua/sttr/init.lua @@ -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 diff --git a/lua/sttr/picker.lua b/lua/sttr/picker.lua new file mode 100644 index 0000000..1388280 --- /dev/null +++ b/lua/sttr/picker.lua @@ -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 diff --git a/lua/sttr/transform.lua b/lua/sttr/transform.lua new file mode 100644 index 0000000..7ce2488 --- /dev/null +++ b/lua/sttr/transform.lua @@ -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