From 7b15a3a4b4fb4dc6be5d53a22c20d68f8181d68b Mon Sep 17 00:00:00 2001 From: Josh Rahm Date: Sun, 18 Oct 2026 15:49:48 -0600 Subject: [extregs] initial commit. added a bunch of useful extregs --- README.md | 75 +++++++++++++++++++++++++++++++++++++++++++ lua/extregs/buffer.lua | 13 ++++++++ lua/extregs/file.lua | 26 +++++++++++++++ lua/extregs/file_basename.lua | 8 +++++ lua/extregs/file_dirname.lua | 8 +++++ lua/extregs/file_stem.lua | 8 +++++ lua/extregs/init.lua | 72 +++++++++++++++++++++++++++++++++++++++++ lua/extregs/osc52.lua | 15 +++++++++ lua/extregs/ssh.lua | 31 ++++++++++++++++++ lua/extregs/storage.lua | 24 ++++++++++++++ lua/extregs/timestamp.lua | 6 ++++ lua/extregs/tmux.lua | 24 ++++++++++++++ lua/extregs/util.lua | 31 ++++++++++++++++++ lua/extregs/uuid.lua | 16 +++++++++ plugin/extregs.lua | 3 ++ tests/storage_spec.lua | 9 ++++++ 16 files changed, 369 insertions(+) create mode 100644 README.md create mode 100644 lua/extregs/buffer.lua create mode 100644 lua/extregs/file.lua create mode 100644 lua/extregs/file_basename.lua create mode 100644 lua/extregs/file_dirname.lua create mode 100644 lua/extregs/file_stem.lua create mode 100644 lua/extregs/init.lua create mode 100644 lua/extregs/osc52.lua create mode 100644 lua/extregs/ssh.lua create mode 100644 lua/extregs/storage.lua create mode 100644 lua/extregs/timestamp.lua create mode 100644 lua/extregs/tmux.lua create mode 100644 lua/extregs/util.lua create mode 100644 lua/extregs/uuid.lua create mode 100644 plugin/extregs.lua create mode 100644 tests/storage_spec.lua diff --git a/README.md b/README.md new file mode 100644 index 0000000..d452f58 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# extregs.nvim + +Useful extended registers for a Neovim build with `has('extregs')`. + +## Install + +Add the repo to `vim.pack.add({...})`. The plugin automatically calls `setup()`. +For options, instead call `require('extregs').setup({...})` from an earlier startup +config file (before Neovim loads `plugin/` runtime files): + +```lua +require('extregs').setup({ + file = { path = '/var/run/user/' .. vim.env.USER .. '/nvim.regfile' }, + ssh = { host = 'rahm@photon', path = '~/.local/state/nvim/regfile' }, + tmux = { buffer = nil }, -- nil: most recent paste buffer + timestamp = { format = '%Y-%m-%dT%H:%M:%S%z' }, +}) +``` + +The plugin registers `osc52`, `tmux`, `buffer`, `file.basename`, `file.stem`, +`file.dirname`, `file.backed`, `ssh.backed`, `timestamp`, and `uuid`. +For extreg syntax use your Neovim patch's `~name` register notation. + +### tmux paste buffer + +`~tmux` reads and writes tmux's current paste buffer. A write runs +`tmux load-buffer -` and a read runs `tmux show-buffer`. +Set `tmux = { buffer = 'nvim' }` to use a named tmux buffer instead. +The content is ordinary text so it can be pasted with tmux's normal paste +binding; no JSON cookie is written. Reading follows the same plaintext rule +as backed-register reads: one line is characterwise, multiple lines linewise. +Writing a linewise register appends a newline. tmux must be accessible from +Neovim (`TMUX` or a compatible tmux environment). + +### Backed-register format + +A structured write produces: + +``` +NVIM_EXTREGS_JSON_V1 +{"lines":["hello"],"type":"v"} +``` + +A read without that first-line magic cookie treats the whole file as plain +text: one line → characterwise (`v`); multiple lines → linewise (`V`). + +### Limitations + +- The source buffer is replaced in memory by `buffer` writes; saving it is separate. +- OSC 52 clipboard reads are terminal-dependent and may time out or fail. +- SSH calls are synchronous, and `ssh.backed` requires passwordless SSH. +- Filename registers are read-only in this initial implementation (as are timestamp/uuid). +- Register callbacks rely on a custom, unreleased Neovim API; validate against your + exact extregs branch before using this in production. +- Missing `has('extregs')` yields a scheduled error notification (not a blocking + error prompt), and registration is skipped. + +### Provider architecture + +Every register has a dedicated module under `lua/extregs/`. Each module exposes +`get_provider(cfg)` and returns a provider object with `read()` and `write(reg)` +methods. The initializer alone owns registration, error notifications, and the +`has('extregs')` guard. To add a register, create a provider module and add one +entry to the `providers` table in `lua/extregs/init.lua`. + +```lua +local provider = require('extregs.tmux').get_provider(cfg.tmux) +-- The initializer wraps these with error handling and registers them: +vim._extregs.register('tmux', + function(reg) return provider:write(reg) end, + function() return provider:read() end) +``` + +The original yank and put callback argument ordering is preserved. The +shared `storage.lua` serializer is used by both file-backed providers. diff --git a/lua/extregs/buffer.lua b/lua/extregs/buffer.lua new file mode 100644 index 0000000..de7c1af --- /dev/null +++ b/lua/extregs/buffer.lua @@ -0,0 +1,13 @@ +local M = {} +function M.get_provider(_) + return { + read = function() + return { lines = vim.api.nvim_buf_get_lines(0, 0, -1, false), type = 'V' } + end, + write = function(_, reg) + vim.api.nvim_buf_set_lines(0, 0, -1, false, reg.lines) + return true + end, + } +end +return M diff --git a/lua/extregs/file.lua b/lua/extregs/file.lua new file mode 100644 index 0000000..0dfe88c --- /dev/null +++ b/lua/extregs/file.lua @@ -0,0 +1,26 @@ +local util = require('extregs.util') +local storage = require('extregs.storage') +local M = {} + +function M.get_provider(cfg) + local function path() return util.path(cfg.path) end + return { + read = function() + local fd, err = io.open(path(), 'rb') + if not fd then error('cannot read ' .. path() .. ': ' .. tostring(err)) end + local data = fd:read('*a') + fd:close() + return storage.decode(data) + end, + write = function(_, reg) + local dest = path() + vim.fn.mkdir(vim.fn.fnamemodify(dest, ':h'), 'p') + local fd, err = io.open(dest, 'wb') + if not fd then error('cannot write ' .. dest .. ': ' .. tostring(err)) end + assert(fd:write(storage.encode(reg))) + assert(fd:close()) + return true + end, + } +end +return M diff --git a/lua/extregs/file_basename.lua b/lua/extregs/file_basename.lua new file mode 100644 index 0000000..de59fea --- /dev/null +++ b/lua/extregs/file_basename.lua @@ -0,0 +1,8 @@ +local util = require('extregs.util') +local M = {} +function M.get_provider(_) + return util.readonly(function() + return vim.fn.fnamemodify(vim.api.nvim_buf_get_name(0), ':t') + end) +end +return M diff --git a/lua/extregs/file_dirname.lua b/lua/extregs/file_dirname.lua new file mode 100644 index 0000000..8ba5e77 --- /dev/null +++ b/lua/extregs/file_dirname.lua @@ -0,0 +1,8 @@ +local util = require('extregs.util') +local M = {} +function M.get_provider(_) + return util.readonly(function() + return vim.fn.fnamemodify(vim.api.nvim_buf_get_name(0), ':h') + end) +end +return M diff --git a/lua/extregs/file_stem.lua b/lua/extregs/file_stem.lua new file mode 100644 index 0000000..49ebc24 --- /dev/null +++ b/lua/extregs/file_stem.lua @@ -0,0 +1,8 @@ +local util = require('extregs.util') +local M = {} +function M.get_provider(_) + return util.readonly(function() + return vim.fn.fnamemodify(vim.fn.fnamemodify(vim.api.nvim_buf_get_name(0), ':t'), ':r') + end) +end +return M diff --git a/lua/extregs/init.lua b/lua/extregs/init.lua new file mode 100644 index 0000000..b9dcbe1 --- /dev/null +++ b/lua/extregs/init.lua @@ -0,0 +1,72 @@ +local M = {} + +M.defaults = { + file = { path = (vim.env.XDG_RUNTIME_DIR or vim.fn.stdpath('state')) .. '/nvim.regfile' }, + ssh = { host = nil, path = '~/.local/state/nvim/regfile' }, + osc52 = { register = '+' }, + tmux = { buffer = nil }, + timestamp = { format = '%Y-%m-%dT%H:%M:%S%z' }, +} + +-- name, module, configuration section; registration semantics are (write, read). +local providers = { + { 'osc52', 'osc52', 'osc52' }, + { 'tmux', 'tmux', 'tmux' }, + { 'buffer', 'buffer' }, + { 'file.basename', 'file_basename' }, + { 'file.stem', 'file_stem' }, + { 'file.dirname', 'file_dirname' }, + { 'file.backed', 'file', 'file' }, + { 'ssh.backed', 'ssh', 'ssh' }, + { 'timestamp', 'timestamp', 'timestamp' }, + { 'uuid', 'uuid' }, +} + +local function notify(msg, level) + vim.schedule(function() + vim.notify('extregs.nvim: ' .. msg, level or vim.log.levels.ERROR) + end) +end + +local function guard(name, fn) + return function(...) + local ok, result = pcall(fn, ...) + if not ok then + notify(name .. ': ' .. tostring(result)) + return false + end + return result + end +end + +local function register(name, provider) + assert(type(provider.read) == 'function', name .. ': missing read()') + assert(type(provider.write) == 'function', name .. ': missing write()') + vim._extregs.register(name, + guard(name, function(reg) + if provider.readonly then + notify(name .. ' is read-only', vim.log.levels.WARN) + return false + end + return provider:write(reg) + end), + guard(name, function() return provider:read() end)) +end + +function M.setup(opts) + if vim.fn.has('extregs') ~= 1 or not (vim._extregs and vim._extregs.register) then + notify("requires a Neovim build with has('extregs') and vim._extregs.register") + return false + end + if M._initialized then return true end + local cfg = vim.tbl_deep_extend('force', vim.deepcopy(M.defaults), opts or {}) + for _, spec in ipairs(providers) do + local name, module, section = unpack(spec) + local provider = require('extregs.' .. module).get_provider(cfg[section] or {}) + register(name, provider) + end + M._initialized = true + return true +end + +return M diff --git a/lua/extregs/osc52.lua b/lua/extregs/osc52.lua new file mode 100644 index 0000000..92fa12d --- /dev/null +++ b/lua/extregs/osc52.lua @@ -0,0 +1,15 @@ +local M = {} +function M.get_provider(cfg) + local clipboard = require('vim.ui.clipboard.osc52') + return { + read = function() + return false + end, + write = function(_, reg) + clipboard.copy(cfg.register)(reg.lines) + return true + end, + } +end + +return M diff --git a/lua/extregs/ssh.lua b/lua/extregs/ssh.lua new file mode 100644 index 0000000..8102434 --- /dev/null +++ b/lua/extregs/ssh.lua @@ -0,0 +1,31 @@ +local util = require('extregs.util') +local storage = require('extregs.storage') +local M = {} + +local function remote_path(path) + -- Expand ~/ on the remote system; quote everything else. + if path:sub(1, 2) == '~/' then + return '"$HOME"/' .. util.shell_quote(path:sub(3)) + end + return util.shell_quote(path) +end + +function M.get_provider(cfg) + assert(type(cfg.path) == 'string' and cfg.path ~= '', 'ssh.path required') + local path = remote_path(cfg.path) + local function exec(script, input) + assert(type(cfg.host) == 'string' and cfg.host:match('^[%w_.%-]+@[%w_.%-]+$'), + 'ssh.host must be user@host') + -- SSH joins remote argv with spaces: quote the *whole* sh -c argument. + return util.system({ 'ssh', '-o', 'BatchMode=yes', cfg.host, + 'sh -c ' .. util.shell_quote(script) }, input, 'ssh') + end + return { + read = function() return storage.decode(exec('cat ' .. path)) end, + write = function(_, reg) + exec('mkdir -p "$(dirname ' .. path .. ')" && cat > ' .. path, storage.encode(reg)) + return true + end, + } +end +return M diff --git a/lua/extregs/storage.lua b/lua/extregs/storage.lua new file mode 100644 index 0000000..c532485 --- /dev/null +++ b/lua/extregs/storage.lua @@ -0,0 +1,24 @@ +local M = {} +M.cookie = 'NVIM_EXTREGS_JSON_V1' + +local function validate(reg) + if type(reg) ~= 'table' or type(reg.lines) ~= 'table' or type(reg.type) ~= 'string' then + error('invalid yankreg: expected { lines = {...}, type = "v"|"V"|blockwise }') + end + return reg +end + +function M.encode(reg) + return M.cookie .. '\n' .. vim.json.encode(validate(reg)) .. '\n' +end + +function M.decode(data) + if data:sub(1, #M.cookie + 1) == M.cookie .. '\n' then + return validate(vim.json.decode(data:sub(#M.cookie + 2))) + end + local lines = vim.split(data, '\n', { plain = true }) + if #lines > 1 and lines[#lines] == '' then table.remove(lines) end + return { lines = lines, type = #lines > 1 and 'V' or 'v' } +end + +return M diff --git a/lua/extregs/timestamp.lua b/lua/extregs/timestamp.lua new file mode 100644 index 0000000..6405285 --- /dev/null +++ b/lua/extregs/timestamp.lua @@ -0,0 +1,6 @@ +local util = require('extregs.util') +local M = {} +function M.get_provider(cfg) + return util.readonly(function() return os.date(cfg.format) end) +end +return M diff --git a/lua/extregs/tmux.lua b/lua/extregs/tmux.lua new file mode 100644 index 0000000..ea0299b --- /dev/null +++ b/lua/extregs/tmux.lua @@ -0,0 +1,24 @@ +local util = require('extregs.util') +local storage = require('extregs.storage') +local M = {} +function M.get_provider(cfg) + local function run(cmd, input) + local argv = { 'tmux', cmd } + if cfg.buffer ~= nil then + assert(type(cfg.buffer) == 'string' and cfg.buffer ~= '', 'tmux.buffer must be a nonempty string') + vim.list_extend(argv, { '-b', cfg.buffer }) + end + if cmd == 'load-buffer' then argv[#argv + 1] = '-' end + return util.system(argv, input, 'tmux') + end + return { + read = function() return storage.decode(run('show-buffer')) end, + write = function(_, reg) + local data = table.concat(reg.lines, '\n') + if reg.type == 'V' then data = data .. '\n' end + run('load-buffer', data) + return true + end, + } +end +return M diff --git a/lua/extregs/util.lua b/lua/extregs/util.lua new file mode 100644 index 0000000..6dd1f00 --- /dev/null +++ b/lua/extregs/util.lua @@ -0,0 +1,31 @@ +local M = {} + +function M.textreg(s) + return { lines = { tostring(s) }, type = 'v' } +end + +function M.readonly(getter) + return { + read = function() return M.textreg(getter()) end, + write = function() return false end, + readonly = true, + } +end + +function M.path(path) + return vim.fn.expand(path) +end + +function M.shell_quote(s) + return "'" .. s:gsub("'", "'\\''") .. "'" +end + +function M.system(argv, input, label) + local result = vim.system(argv, { text = false, stdin = input }):wait() + if result.code ~= 0 then + error((label or argv[1]) .. ': ' .. (result.stderr or ('exit status ' .. result.code))) + end + return result.stdout or '' +end + +return M diff --git a/lua/extregs/uuid.lua b/lua/extregs/uuid.lua new file mode 100644 index 0000000..e23a37f --- /dev/null +++ b/lua/extregs/uuid.lua @@ -0,0 +1,16 @@ +local util = require('extregs.util') +local M = {} +function M.get_provider(_) + return util.readonly(function() + local bytes = vim.uv.random(16) + assert(bytes and #bytes == 16, 'unable to obtain random bytes') + local b = { bytes:byte(1, 16) } + b[7] = (b[7] % 16) + 64 + b[9] = (b[9] % 64) + 128 + local h = {} + for i = 1, 16 do h[i] = string.format('%%02x', b[i]) end + return table.concat(h, '', 1, 4) .. '-' .. table.concat(h, '', 5, 6) .. '-' .. + table.concat(h, '', 7, 8) .. '-' .. table.concat(h, '', 9, 10) .. '-' .. table.concat(h, '', 11, 16) + end) +end +return M diff --git a/plugin/extregs.lua b/plugin/extregs.lua new file mode 100644 index 0000000..095dd1b --- /dev/null +++ b/plugin/extregs.lua @@ -0,0 +1,3 @@ +-- Register built-in extregs on startup. Optional customization may call setup() +-- earlier; subsequent calls are harmless. +require('extregs').setup() diff --git a/tests/storage_spec.lua b/tests/storage_spec.lua new file mode 100644 index 0000000..7da2bf7 --- /dev/null +++ b/tests/storage_spec.lua @@ -0,0 +1,9 @@ +-- Can be run under Neovim with: nvim --headless -u NONE -l tests/storage_spec.lua +vim.opt.rtp:prepend(vim.fn.getcwd()) +local s = require('extregs.storage') +local reg = { lines = { 'one', 'two' }, type = 'V', size = 2 } +local got = s.decode(s.encode(reg)) +assert(vim.deep_equal(got, reg)) +assert(vim.deep_equal(s.decode('hello'), { lines = { 'hello' }, type = 'v' })) +assert(vim.deep_equal(s.decode('hello\nworld\n'), { lines = { 'hello', 'world' }, type = 'V' })) +print('storage tests passed') -- cgit