├── .gitignore ├── plugin └── nvim-macros.lua ├── LICENSE ├── lua └── nvim-macros │ ├── util.lua │ ├── base64.lua │ ├── json.lua │ └── init.lua └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .luacheckrc 2 | .luarc.json 3 | -------------------------------------------------------------------------------- /plugin/nvim-macros.lua: -------------------------------------------------------------------------------- 1 | vim.api.nvim_create_user_command("MacroYank", function(opts) 2 | require("nvim-macros").yank(unpack(opts.fargs)) 3 | end, { nargs = "*" }) 4 | 5 | vim.api.nvim_create_user_command("MacroSave", function(opts) 6 | require("nvim-macros").save_macro(unpack(opts.fargs)) 7 | end, { nargs = "*" }) 8 | 9 | vim.api.nvim_create_user_command("MacroSelect", function() 10 | require("nvim-macros").select_and_yank_macro() 11 | end, {}) 12 | 13 | vim.api.nvim_create_user_command("MacroDelete", function() 14 | require("nvim-macros").delete_macro() 15 | end, {}) 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Kartik Rao 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lua/nvim-macros/util.lua: -------------------------------------------------------------------------------- 1 | local base64 = require("nvim-macros.base64") 2 | 3 | local M = {} 4 | 5 | -- Print error message 6 | M.notify = function(msg, level) 7 | if not level then 8 | level = "info" 9 | end 10 | vim.notify(msg, vim.log.levels[level:upper()], { title = "nvim-macros" }) 11 | end 12 | 13 | -- Get default register ("unnamed" or "unnamedplus") 14 | M.get_default_register = function() 15 | local clipboardFlags = vim.split(vim.api.nvim_get_option("clipboard"), ",") 16 | if vim.tbl_contains(clipboardFlags, "unnamedplus") then 17 | return "+" 18 | end 19 | if vim.tbl_contains(clipboardFlags, "unnamed") then 20 | return "*" 21 | end 22 | return '"' 23 | end 24 | 25 | -- Get register input 26 | M.get_register_input = function(prompt, default_register) 27 | local valid_registers = "[a-z0-9]" 28 | local register = vim.fn.input(prompt) 29 | 30 | while not (register:match("^" .. valid_registers .. "$") or register == "") do 31 | M.notify( 32 | "Invalid register: `" .. register .. "`. Register must be a single lowercase letter or number 1-9.", 33 | "error" 34 | ) 35 | register = vim.fn.input(prompt) 36 | end 37 | 38 | if register == "" then 39 | register = default_register 40 | M.notify("No register specified. Using default `" .. default_register .. "`.") 41 | end 42 | 43 | return register 44 | end 45 | 46 | -- Decode and set macro to register 47 | M.set_decoded_macro_to_register = function(encoded_content, target_register) 48 | if not encoded_content or encoded_content == "" then 49 | M.notify("Empty encoded content. Cannot set register `" .. target_register .. "`.", "error") 50 | return 51 | end 52 | 53 | local decoded_content = base64.dec(encoded_content) 54 | if not decoded_content or decoded_content == "" then 55 | M.notify("Failed to decode. Register `" .. target_register .. "` remains unchanged.", "error") 56 | return 57 | end 58 | 59 | vim.fn.setreg(target_register, decoded_content) 60 | end 61 | 62 | -- Set macro to register (Escaped term codes) 63 | M.set_macro_to_register = function(macro_content) 64 | if not macro_content then 65 | M.notify("Empty macro content. Cannot set to default register.", "error") 66 | return 67 | end 68 | 69 | local default_register = M.get_default_register() 70 | vim.fn.setreg(default_register, macro_content) 71 | vim.fn.setreg('"', macro_content) 72 | end 73 | 74 | return M 75 | -------------------------------------------------------------------------------- /lua/nvim-macros/base64.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local extract = _G.bit32 and _G.bit32.extract -- Lua 5.2/Lua 5.3 in compatibility mode 4 | if not extract then 5 | if _G.bit then -- LuaJIT 6 | local shl, shr, band = _G.bit.lshift, _G.bit.rshift, _G.bit.band 7 | extract = function(v, from, width) 8 | return band(shr(v, from), shl(1, width) - 1) 9 | end 10 | elseif _G._VERSION == "Lua 5.1" then 11 | extract = function(v, from, width) 12 | local w = 0 13 | local flag = 2 ^ from 14 | for i = 0, width - 1 do 15 | local flag2 = flag + flag 16 | if v % flag2 >= flag then 17 | w = w + 2 ^ i 18 | end 19 | flag = flag2 20 | end 21 | return w 22 | end 23 | else -- Lua 5.3+ 24 | extract = load([[return function( v, from, width ) 25 | return ( v >> from ) & ((1 << width) - 1) 26 | end]])() 27 | end 28 | end 29 | 30 | M.makeencoder = function(s62, s63, spad) 31 | local encoder = {} 32 | for b64code, char in pairs({ 33 | [0] = "A", 34 | "B", 35 | "C", 36 | "D", 37 | "E", 38 | "F", 39 | "G", 40 | "H", 41 | "I", 42 | "J", 43 | "K", 44 | "L", 45 | "M", 46 | "N", 47 | "O", 48 | "P", 49 | "Q", 50 | "R", 51 | "S", 52 | "T", 53 | "U", 54 | "V", 55 | "W", 56 | "X", 57 | "Y", 58 | "Z", 59 | "a", 60 | "b", 61 | "c", 62 | "d", 63 | "e", 64 | "f", 65 | "g", 66 | "h", 67 | "i", 68 | "j", 69 | "k", 70 | "l", 71 | "m", 72 | "n", 73 | "o", 74 | "p", 75 | "q", 76 | "r", 77 | "s", 78 | "t", 79 | "u", 80 | "v", 81 | "w", 82 | "x", 83 | "y", 84 | "z", 85 | "0", 86 | "1", 87 | "2", 88 | "3", 89 | "4", 90 | "5", 91 | "6", 92 | "7", 93 | "8", 94 | "9", 95 | s62 or "+", 96 | s63 or "/", 97 | spad or "=", 98 | }) do 99 | encoder[b64code] = char:byte() 100 | end 101 | return encoder 102 | end 103 | 104 | M.makedecoder = function(s62, s63, spad) 105 | local decoder = {} 106 | for b64code, charcode in pairs(M.makeencoder(s62, s63, spad)) do 107 | decoder[charcode] = b64code 108 | end 109 | return decoder 110 | end 111 | 112 | local DEFAULT_ENCODER = M.makeencoder() 113 | local DEFAULT_DECODER = M.makedecoder() 114 | 115 | local char, concat = string.char, table.concat 116 | 117 | M.enc = function(str, encoder, usecaching) 118 | encoder = encoder or DEFAULT_ENCODER 119 | local t, k, n = {}, 1, #str 120 | local lastn = n % 3 121 | local cache = {} 122 | for i = 1, n - lastn, 3 do 123 | local a, b, c = str:byte(i, i + 2) 124 | local v = a * 0x10000 + b * 0x100 + c 125 | local s 126 | if usecaching then 127 | s = cache[v] 128 | if not s then 129 | s = char( 130 | encoder[extract(v, 18, 6)], 131 | encoder[extract(v, 12, 6)], 132 | encoder[extract(v, 6, 6)], 133 | encoder[extract(v, 0, 6)] 134 | ) 135 | cache[v] = s 136 | end 137 | else 138 | s = char( 139 | encoder[extract(v, 18, 6)], 140 | encoder[extract(v, 12, 6)], 141 | encoder[extract(v, 6, 6)], 142 | encoder[extract(v, 0, 6)] 143 | ) 144 | end 145 | t[k] = s 146 | k = k + 1 147 | end 148 | if lastn == 2 then 149 | local a, b = str:byte(n - 1, n) 150 | local v = a * 0x10000 + b * 0x100 151 | t[k] = char(encoder[extract(v, 18, 6)], encoder[extract(v, 12, 6)], encoder[extract(v, 6, 6)], encoder[64]) 152 | elseif lastn == 1 then 153 | local v = str:byte(n) * 0x10000 154 | t[k] = char(encoder[extract(v, 18, 6)], encoder[extract(v, 12, 6)], encoder[64], encoder[64]) 155 | end 156 | return concat(t) 157 | end 158 | 159 | M.dec = function(b64, decoder, usecaching) 160 | decoder = decoder or DEFAULT_DECODER 161 | local pattern = "[^%w%+%/%=]" 162 | if decoder then 163 | local s62, s63 164 | for charcode, b64code in pairs(decoder) do 165 | if b64code == 62 then 166 | s62 = charcode 167 | elseif b64code == 63 then 168 | s63 = charcode 169 | end 170 | end 171 | pattern = ("[^%%w%%%s%%%s%%=]"):format(char(s62), char(s63)) 172 | end 173 | b64 = b64:gsub(pattern, "") 174 | local cache = usecaching and {} 175 | local t, k = {}, 1 176 | local n = #b64 177 | local padding = b64:sub(-2) == "==" and 2 or b64:sub(-1) == "=" and 1 or 0 178 | for i = 1, padding > 0 and n - 4 or n, 4 do 179 | local a, b, c, d = b64:byte(i, i + 3) 180 | local s 181 | if usecaching then 182 | local v0 = a * 0x1000000 + b * 0x10000 + c * 0x100 + d 183 | s = cache[v0] 184 | if not s then 185 | local v = decoder[a] * 0x40000 + decoder[b] * 0x1000 + decoder[c] * 0x40 + decoder[d] 186 | s = char(extract(v, 16, 8), extract(v, 8, 8), extract(v, 0, 8)) 187 | cache[v0] = s 188 | end 189 | else 190 | local v = decoder[a] * 0x40000 + decoder[b] * 0x1000 + decoder[c] * 0x40 + decoder[d] 191 | s = char(extract(v, 16, 8), extract(v, 8, 8), extract(v, 0, 8)) 192 | end 193 | t[k] = s 194 | k = k + 1 195 | end 196 | if padding == 1 then 197 | local a, b, c = b64:byte(n - 3, n - 1) 198 | local v = decoder[a] * 0x40000 + decoder[b] * 0x1000 + decoder[c] * 0x40 199 | t[k] = char(extract(v, 16, 8), extract(v, 8, 8)) 200 | elseif padding == 2 then 201 | local a, b = b64:byte(n - 3, n - 2) 202 | local v = decoder[a] * 0x40000 + decoder[b] * 0x1000 203 | t[k] = char(extract(v, 16, 8)) 204 | end 205 | return concat(t) 206 | end 207 | 208 | return M 209 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nvim-macros 📝 2 | 3 | nvim-macros is your go-to Neovim plugin for supercharging your macro game! 🚀 It's all about making macro management in Neovim a breeze. Say goodbye to the fuss and hello to efficiency! This plugin lets you save, yank, and run your macros like a pro, and even handles those pesky special characters with ease. 4 | 5 | ## Why You'll Love nvim-macros 😍 6 | 7 | - **Yank Macros** 🎣: Grab macros from any register and set them up for action in your default register with just a command. 8 | - **Save Macros** 💾: Stash your precious macros in a JSON file. Save them with all the fancy termcodes and the raw version - ready when you need them! 9 | - **Select & Yank** 📋: Pick a macro from your saved collection and yank it into a register, ready for its moment in the spotlight. 10 | - **Smart Encoding/Decoding** 🤓: nvim-macros speaks Base64 fluently, so it effortlessly handles macros with special characters. 11 | - **Your Storage, Your Rules** 🗂️: Point nvim-macros to your chosen JSON file for macro storage. It's your macro library, after all! 12 | - **Pretty Printing** 🎨: Choose your JSON formatter ([jq](https://jqlang.github.io/jq/) or [yq](https://github.com/mikefarah/yq)) to keep your JSON file looking sharp. No more squinting at a jumbled mess of macros! 13 | - **Backup & Restore** 📦: Made a mess editing the JSON file? No worries! nvim-macros keeps a backup of your JSON file, so you can always restore your macros to their former glory auto-magically! 14 | 15 | ## Getting Started 🚀 16 | 17 | Time to get nvim-macros into your Neovim setup! If you're rolling with [lazy.nvim](https://github.com/folke/lazy.nvim), just pop this line into your plugin configuration: 18 | 19 | ```lua 20 | { 21 | "kr40/nvim-macros", 22 | cmd = {"MacroSave", "MacroYank", "MacroSelect", "MacroDelete"}, 23 | opts = { 24 | 25 | json_file_path = vim.fs.normalize(vim.fn.stdpath("config") .. "/macros.json"), -- Location where the macros will be stored 26 | default_macro_register = "q", -- Use as default register for :MacroYank and :MacroSave and :MacroSelect Raw functions 27 | json_formatter = "none", -- can be "none" | "jq" | "yq" used to pretty print the json file (jq or yq must be installed!) 28 | 29 | } 30 | } 31 | ``` 32 | 33 | ## How to Use 🛠️ 34 | 35 | Once you've got nvim-macros installed, Neovim is your macro playground! 🎉 36 | 37 | - **:MacroYank [register]**: Yanks a macro from a register. If you don't specify, it'll politely ask you to choose one. 38 | - **:MacroSave [register]**: Saves a macro into the book of legends (aka your JSON file). It'll prompt for a register if you're feeling indecisive. 39 | - **:MacroSelect**: Brings up your macro menu. Pick one, and it'll be ready for action. 40 | - **:MacroDelete**: Summon a list of your macros, then select one to permanently vanish it from your collection, as if it never existed. 41 | 42 | ### Example 🌟 43 | 44 | Imagine you've got a nifty macro recorded in the **q** register that magically turns the current line into a to-do list item. After recording it, just summon **:MacroYank q** to yank the macro. Then, you can elegantly bind it to a key sequence in your Neovim setup like this: 45 | 46 | ```lua 47 | vim.keymap.set('n', 't', '^i-[]', { remap = true }) 48 | ``` 49 | 50 | **_📝 Note: We highly recommend setting remap = true to ensure your macro runs as smoothly as if you were performing a magic trick yourself!_** 51 | 52 | ## Making It Yours 🎨 53 | 54 | nvim-macros loves to fit in just right. Set up your custom options like so: 55 | 56 | ```lua 57 | require('nvim-macros').setup({ 58 | json_file_path = "/your/very/own/path/to/macros.json", 59 | default_macro_register = "a", 60 | json_formatter = "jq", 61 | }) 62 | ``` 63 | 64 | Fine with the defaults? No worries! nvim-macros will go with the flow and use the [defaults](#getting-started-🚀) no need to call `setup` or `opts`. 65 | 66 | ## Join the Party 🎉 67 | 68 | Got ideas? Found a bug? Jump in and contribute! Whether it's a pull request or a hearty discussion in the issues, your input is what makes the nvim-macros party rock. 69 | 70 | ## To-Do 📝 71 | 72 | nvim-macros is on a quest to make your Neovim experience even more magical! Here are some enchantments we're looking to add: 73 | 74 | - [ ] **Macro Editing**: Forge a way to edit your macros directly within Neovim. This will involve summoning a macro from the JSON grimoire into a buffer, weaving your edits, and then sealing the updated macro back into the tome. 75 | 76 | - [ ] **Macro Tags/Categories**: Introduce the mystic arts of tagging and categorizing your macros. This will allow you to filter and search through your macros based on their assigned tags or categories, managing your macro arsenal with unparalleled ease. 77 | 78 | - [ ] **Macro Sharing/Importing**: Develop an incantation to export and import macros, empowering you to share your macros with fellow sorcerers or swiftly set up your macro sanctum on a new system. 79 | 80 | - [ ] **Macro Analytics**: Offer a crystal ball to gaze into your macro usage, revealing insights such as the frequency of use, helping you to understand your workflow and refine your arsenal of macros. 81 | 82 | Feel free to jump in and contribute if you're drawn to any of these upcoming features or if you have your own ideas to sprinkle some extra magic into nvim-macros! 🌟 83 | 84 | ## Inspiration 🌱 85 | 86 | nvim-macros didn't just spring out of thin air; it's been nurtured by some awesome ideas and projects in the Neovim community. Here's a shoutout to the sparks that ignited this project: 87 | 88 | - [nvim-macroni by Jesse Leite](https://github.com/jesseleite/nvim-macroni): Jesse's enlightening talk and his brilliantly simple plugin sowed the seeds for nvim-macros. It's all about taking those little steps towards macro mastery! 89 | - [cd-project.nvim by LintaoAmons](https://github.com/LintaoAmons/cd-project.nvim): The innovative use of a JSON file for data storage in this project opened up new pathways for how nvim-macros could manage and store macro magic efficiently. 90 | 91 | Big thanks to the creators and contributors of these projects! 🙏 92 | -------------------------------------------------------------------------------- /lua/nvim-macros/json.lua: -------------------------------------------------------------------------------- 1 | local util = require("nvim-macros.util") 2 | 3 | local M = {} 4 | 5 | -- Validate JSON content 6 | local validate_json = function(decoded_content) 7 | if 8 | not decoded_content 9 | or type(decoded_content) ~= "table" 10 | or not decoded_content.macros 11 | or type(decoded_content.macros) ~= "table" 12 | then 13 | return false 14 | end 15 | 16 | for _, macro in ipairs(decoded_content.macros) do 17 | if 18 | type(macro) ~= "table" 19 | or type(macro.name) ~= "string" 20 | or macro.name == "" 21 | or type(macro.raw) ~= "string" 22 | or macro.raw == "" 23 | or type(macro.content) ~= "string" 24 | or macro.content == "" 25 | then 26 | return false 27 | end 28 | end 29 | 30 | return true 31 | end 32 | 33 | -- Pretty print JSON content using jq or yq 34 | local pretty_print_json = function(data, formatter) 35 | local json_str = vim.fn.json_encode(data) 36 | 37 | if formatter == "jq" then 38 | if vim.fn.executable("jq") == 0 then 39 | util.notify("jq is not installed. Falling back to default 'none'.", "error") 40 | return json_str 41 | end 42 | local cmd = "echo " .. vim.fn.shellescape(json_str) .. " | jq --monochrome-output ." 43 | return vim.fn.system(cmd) 44 | elseif formatter == "yq" then 45 | if vim.fn.executable("yq") == 0 then 46 | util.notify("yq is not installed. Falling back to default 'none'.", "error") 47 | return json_str 48 | end 49 | local cmd = "echo " 50 | .. vim.fn.shellescape(json_str) 51 | .. " | yq -P --output-format=json --input-format=json --no-colors ." 52 | return vim.fn.system(cmd) 53 | else 54 | return json_str 55 | end 56 | end 57 | 58 | -- Get the most recent backup file 59 | local get_latest_backup = function(backup_dir) 60 | local p = io.popen('ls -t "' .. backup_dir .. '"') 61 | if p then 62 | local latest_backup = p:read("*l") 63 | p:close() 64 | return latest_backup and backup_dir .. "/" .. latest_backup 65 | else 66 | return nil 67 | end 68 | end 69 | 70 | -- Restore from the most recent backup 71 | local restore_from_backup = function(backup_file, original_file) 72 | if not backup_file or not original_file then 73 | return false 74 | end 75 | 76 | local restore_cmd = "cp -f '" .. backup_file .. "' '" .. original_file .. "'" 77 | return os.execute(restore_cmd) == 0 78 | end 79 | 80 | local cleanup_old_backups = function(backup_dir, keep_last_n) 81 | local p = io.popen('ls -t "' .. backup_dir .. '"') 82 | if not p then 83 | return nil 84 | end 85 | 86 | local backups = {} 87 | for filename in p:lines() do 88 | table.insert(backups, filename) 89 | end 90 | p:close() 91 | 92 | for i = keep_last_n + 1, #backups do 93 | local backup_to_delete = backup_dir .. "/" .. backups[i] 94 | os.remove(backup_to_delete) 95 | end 96 | end 97 | 98 | -- Handle JSON file read and write (r, w) 99 | M.handle_json_file = function(json_formatter, json_file_path, mode, data) 100 | if not json_file_path or json_file_path == "" then 101 | util.notify("Invalid JSON file path.", "error") 102 | return mode == "r" and { macros = {} } or nil 103 | end 104 | 105 | local file_path = json_file_path 106 | local backup_dir = vim.fn.stdpath("data") .. "/nvim-macros/backups" 107 | vim.fn.mkdir(backup_dir, "p") 108 | 109 | if mode == "r" then 110 | local file = io.open(file_path, "r") 111 | if not file then 112 | local latest_backup = get_latest_backup(backup_dir) 113 | if latest_backup then 114 | if restore_from_backup(latest_backup, file_path) then 115 | util.notify("No JSON found. Restored from the most recent backup.") 116 | file = io.open(file_path, "r") 117 | end 118 | else 119 | util.notify("No JSON found. Creating new file: " .. file_path) 120 | file = io.open(file_path, "w") 121 | if file then 122 | local content = vim.fn.json_encode({ macros = {} }) 123 | file:write(content) 124 | file:close() 125 | return { macros = {} } 126 | else 127 | util.notify("Failed to create new file: " .. file_path, "error") 128 | return nil 129 | end 130 | end 131 | end 132 | 133 | if file then 134 | local content = file:read("*a") 135 | file:close() 136 | 137 | if not content or content == "" then 138 | local latest_backup = get_latest_backup(backup_dir) 139 | if latest_backup then 140 | if restore_from_backup(latest_backup, file_path) then 141 | util.notify("File is empty. Restored from most recent backup.", "error") 142 | return M.handle_json_file(json_formatter, json_file_path, mode, data) 143 | end 144 | else 145 | util.notify("File is empty. Initializing with default structure.", "error") 146 | return { macros = {} } 147 | end 148 | end 149 | 150 | if content then 151 | local status, decoded_content = pcall(vim.fn.json_decode, content) 152 | if status and validate_json(decoded_content) then 153 | return decoded_content 154 | else 155 | util.notify("Invalid JSON content. Attempting to restore from backup.", "error") 156 | local latest_backup = get_latest_backup(backup_dir) 157 | if latest_backup and restore_from_backup(latest_backup, file_path) then 158 | util.notify("Successfully restored from backup.") 159 | return M.handle_json_file(json_formatter, json_file_path, mode, data) 160 | else 161 | util.notify("Failed to restore from backup. Manual check required.", "error") 162 | return nil 163 | end 164 | end 165 | end 166 | end 167 | elseif mode == "w" then 168 | local backup_file_path = backup_dir .. "/" .. os.date("%Y%m%d%H%M%S") .. "_macros.json.bak" 169 | 170 | local file = io.open(file_path, "w") 171 | if not file then 172 | util.notify("Unable to write to the file.", "error") 173 | return nil 174 | end 175 | 176 | local content = (json_formatter == "jq" or json_formatter == "yq") and pretty_print_json(data, json_formatter) 177 | or vim.fn.json_encode(data) 178 | 179 | file:write(content) 180 | file:close() 181 | 182 | os.execute("cp -f '" .. file_path .. "' '" .. backup_file_path .. "'") 183 | cleanup_old_backups(backup_dir, 3) 184 | else 185 | util.notify("Invalid mode: '" .. mode .. "'. Use 'r' or 'w'.", "error") 186 | end 187 | end 188 | 189 | return M 190 | -------------------------------------------------------------------------------- /lua/nvim-macros/init.lua: -------------------------------------------------------------------------------- 1 | local base64 = require("nvim-macros.base64") 2 | local util = require("nvim-macros.util") 3 | local json = require("nvim-macros.json") 4 | 5 | -- Default configuration 6 | ---@class Config 7 | ---@field json_file_path string 8 | ---@field default_macro_register string 9 | ---@field json_formatter "none" | "jq" | "yq" 10 | local config = { 11 | json_file_path = vim.fs.normalize(vim.fn.stdpath("config") .. "/macros.json"), 12 | default_macro_register = "q", 13 | json_formatter = "none", 14 | } 15 | 16 | local M = {} 17 | 18 | -- Initialize with user config 19 | ---@param user_config? Config 20 | M.setup = function(user_config) 21 | if user_config ~= nil then 22 | for key, value in pairs(user_config) do 23 | if config[key] ~= nil then 24 | config[key] = value 25 | else 26 | util.notify("Invalid config key: " .. key, "error") 27 | end 28 | end 29 | end 30 | end 31 | 32 | M.get_config = function() 33 | return config 34 | end 35 | 36 | -- Yank macro from register to default register 37 | M.yank = function(register) 38 | local valid_registers = "[a-z0-9]" 39 | if not register or register == "" then 40 | register = util.get_register_input("Specify a register to yank from: ", config.default_macro_register) 41 | end 42 | 43 | while not (register:match("^" .. valid_registers .. "$")) do 44 | util.notify( 45 | "Invalid register: `" .. register .. "`. Register must be a single lowercase letter or number 1-9.", 46 | "error" 47 | ) 48 | 49 | register = util.get_register_input("Specify a register to yank from: ", config.default_macro_register) 50 | end 51 | 52 | local register_content = vim.fn.getreg(register) 53 | if not register_content or register_content == "" then 54 | util.notify("Register `" .. register .. "` is empty or invalid!", "error") 55 | return 56 | end 57 | 58 | local macro = vim.fn.keytrans(register_content) 59 | util.set_macro_to_register(macro) 60 | util.notify("Yanked macro from `" .. register .. "` to clipboard.") 61 | end 62 | 63 | -- Execute macro (for key mappings) 64 | M.run = function(macro) 65 | if not macro then 66 | util.notify("Macro is empty. Cannot run.", "error") 67 | return 68 | end 69 | 70 | vim.cmd.normal(vim.api.nvim_replace_termcodes(macro, true, true, true)) 71 | end 72 | 73 | -- Save macro to JSON (Raw and Escaped) 74 | M.save_macro = function(register) 75 | local valid_registers = "[a-z0-9]" 76 | if not register or register == "" then 77 | register = util.get_register_input("Specify a register to save from: ", config.default_macro_register) 78 | end 79 | 80 | while not (register:match("^" .. valid_registers .. "$")) do 81 | util.notify( 82 | "Invalid register: `" .. register .. "`. Register must be a single lowercase letter or number 1-9.", 83 | "error" 84 | ) 85 | 86 | register = util.get_register_input("Specify a register to save from: ", config.default_macro_register) 87 | end 88 | 89 | local register_content = vim.fn.getreg(register) 90 | if not register_content or register_content == "" then 91 | util.notify("Register `" .. register .. "` is empty or invalid!", "error") 92 | return 93 | end 94 | 95 | local name = vim.fn.input("Name your macro: ") 96 | if not name or name == "" then 97 | util.notify("Invalid or empty macro name.", "error") 98 | return 99 | end 100 | 101 | local macro = vim.fn.keytrans(register_content) 102 | local macro_raw = base64.enc(register_content) 103 | 104 | local macros = json.handle_json_file(config.json_formatter, config.json_file_path, "r") 105 | if macros then 106 | table.insert(macros.macros, { name = name, content = macro, raw = macro_raw }) 107 | json.handle_json_file(config.json_formatter, config.json_file_path, "w", macros) 108 | util.notify("Macro `" .. name .. "` saved.") 109 | end 110 | end 111 | 112 | -- Delete macro from JSON file 113 | M.delete_macro = function() 114 | local macros = json.handle_json_file(config.json_formatter, config.json_file_path, "r") 115 | if not macros or not macros.macros or #macros.macros == 0 then 116 | util.notify("No macros to delete.", "error") 117 | return 118 | end 119 | 120 | local choices = {} 121 | local name_to_index_map = {} 122 | for index, macro in ipairs(macros.macros) do 123 | if macro.name then 124 | local display_text = macro.name .. " | " .. string.sub(macro.content, 1, 150) 125 | table.insert(choices, display_text) 126 | name_to_index_map[display_text] = index 127 | end 128 | end 129 | 130 | if next(choices) == nil then 131 | util.notify("No valid macros for deletion.", "error") 132 | return 133 | end 134 | 135 | vim.ui.select(choices, { prompt = "Select a macro to delete:" }, function(choice) 136 | if not choice then 137 | util.notify("Macro deletion cancelled.", "error") 138 | return 139 | end 140 | 141 | local macro_index = name_to_index_map[choice] 142 | local macro_name = macros.macros[macro_index].name 143 | if not macro_index then 144 | util.notify("Selected macro `" .. choice .. "` is invalid.", "error") 145 | return 146 | end 147 | 148 | table.remove(macros.macros, macro_index) 149 | json.handle_json_file(config.json_formatter, config.json_file_path, "w", macros) 150 | util.notify("Macro `" .. macro_name .. "` deleted.") 151 | end) 152 | end 153 | 154 | -- Select and yank macro from JSON (Raw or Escaped) 155 | M.select_and_yank_macro = function() 156 | local macros = json.handle_json_file(config.json_formatter, config.json_file_path, "r") 157 | if not macros or not macros.macros or #macros.macros == 0 then 158 | util.notify("No macros to select.", "error") 159 | return 160 | end 161 | 162 | local choices = {} 163 | local name_to_content_map = {} 164 | local name_to_encoded_content_map = {} 165 | local name_to_index_map = {} 166 | for index, macro in ipairs(macros.macros) do 167 | if macro.name and macro.content and macro.raw then 168 | local display_text = macro.name .. " | " .. string.sub(macro.content, 1, 150) 169 | table.insert(choices, display_text) 170 | name_to_index_map[display_text] = index 171 | name_to_content_map[display_text] = macro.content 172 | name_to_encoded_content_map[display_text] = macro.raw 173 | end 174 | end 175 | 176 | if next(choices) == nil then 177 | util.notify("No valid macros to yank.", "error") 178 | return 179 | end 180 | 181 | vim.ui.select(choices, { prompt = "Select a macro:" }, function(choice) 182 | if not choice then 183 | util.notify("Macro selection canceled.", "error") 184 | return 185 | end 186 | 187 | local macro_index = name_to_index_map[choice] 188 | local macro_name = macros.macros[macro_index].name 189 | local macro_content = name_to_content_map[choice] 190 | local encoded_content = name_to_encoded_content_map[choice] 191 | if not macro_content or not encoded_content then 192 | util.notify("Selected macro `" .. choice .. "` has missing content.", "error") 193 | return 194 | end 195 | 196 | local yank_option = vim.fn.input("Yank as (1) Escaped, (2) Raw Macro: ") 197 | 198 | if yank_option == "1" then 199 | util.set_macro_to_register(macro_content) 200 | util.notify("Yanked macro `" .. macro_name .. "` to clipboard.") 201 | elseif yank_option == "2" then 202 | local valid_registers = "[a-z0-9]" 203 | local target_register = 204 | util.get_register_input("Specify a register to yank the raw macro to: ", config.default_macro_register) 205 | 206 | while not (target_register:match("^" .. valid_registers .. "$")) do 207 | util.notify( 208 | "Invalid register: `" 209 | .. target_register 210 | .. "`. Register must be a single lowercase letter or number 1-9.", 211 | "error" 212 | ) 213 | 214 | target_register = util.get_register_input( 215 | "Specify a register to yank the raw macro to: ", 216 | config.default_macro_register 217 | ) 218 | end 219 | 220 | util.set_decoded_macro_to_register(encoded_content, target_register) 221 | util.notify("Yanked raw macro `" .. macro_name .. "` into register `" .. target_register .. "`.") 222 | else 223 | util.notify("Invalid yank option selected.", "error") 224 | end 225 | end) 226 | end 227 | 228 | return M 229 | --------------------------------------------------------------------------------