diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2bc0def --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.luacheckrc +.luarc.json diff --git a/README.md b/README.md index bd1725f..7c508f5 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ nvim-macros is your go-to Neovim plugin for supercharging your macro game! 🚀 - **Select & Yank** 📋: Pick a macro from your saved collection and yank it into a register, ready for its moment in the spotlight. - **Smart Encoding/Decoding** 🤓: nvim-macros speaks Base64 fluently, so it effortlessly handles macros with special characters. - **Your Storage, Your Rules** 🗂️: Point nvim-macros to your chosen JSON file for macro storage. It's your macro library, after all! +- **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! +- **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! ## Getting Started 🚀 @@ -19,8 +21,11 @@ Time to get nvim-macros into your Neovim setup! If you're rolling with [lazy.nvi "kr40/nvim-macros", cmd = {"MacroSave", "MacroYank", "MacroSelect", "MacroDelete"}, opts = { - json_file_path = vim.fs.normalize(vim.fn.stdpath("config") .. "/macros.json"), -- Optional + + json_file_path = vim.fs.normalize(vim.fn.stdpath("config") .. "/macros.json"), -- Location where the macros will be stored default_macro_register = "q", -- Use as default register for :MacroYank and :MacroSave and :MacroSelect Raw functions + json_formatter = "none", -- can be "none" | "jq" | "yq" used to pretty print the json file (jq or yq must be installed!) + } } ``` @@ -46,15 +51,17 @@ vim.keymap.set('n', 't', '^i-[]', { remap = tr ## Making It Yours 🎨 -nvim-macros loves to fit in just right. Set up your JSON file path like so: +nvim-macros loves to fit in just right. Set up your custom options like so: ```lua require('nvim-macros').setup({ - json_file_path = '/your/very/own/path/to/macros.json' + json_file_path = "/your/very/own/path/to/macros.json", + default_macro_register = "a", + json_formatter = "jq", }) ``` -No config? No worries! nvim-macros will go with the flow and use a default path. +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`. ## Join the Party 🎉 diff --git a/lua/nvim-macros/init.lua b/lua/nvim-macros/init.lua index f791c5c..22e389b 100644 --- a/lua/nvim-macros/init.lua +++ b/lua/nvim-macros/init.lua @@ -3,21 +3,27 @@ local util = require("nvim-macros.util") local json = require("nvim-macros.json") -- Default configuration +---@class Config +---@field json_file_path string +---@field default_macro_register string +---@field json_formatter "none" | "jq" | "yq" local config = { json_file_path = vim.fs.normalize(vim.fn.stdpath("config") .. "/macros.json"), default_macro_register = "q", + json_formatter = "none", } local M = {} -- Initialize with user config -function M.setup(user_config) +---@param user_config? Config +M.setup = function(user_config) if user_config ~= nil then for key, value in pairs(user_config) do if config[key] ~= nil then config[key] = value else - util.print_message("Invalid config key: " .. key) + util.print_error("Invalid config key: " .. key) end end end @@ -25,17 +31,20 @@ end -- Yank macro from register to default register M.yank = function(register) + local valid_registers = "[a-z0-9]" if not register or register == "" then - register = vim.fn.input("Specify a register to yank from: ") + register = util.get_register_input("Specify a register to yank from: ", config.default_macro_register) end - if not register or register == "" then - register = config.default_macro_register - util.print_info("No register specified. Using default `" .. config.default_macro_register .. "`.") + while not (register:match("^" .. valid_registers .. "$")) do + util.print_error( + "Invalid register: `" .. register .. "`. Register must be a single lowercase letter or number 1-9." + ) + + register = util.get_register_input("Specify a register to yank from: ", config.default_macro_register) end local register_content = vim.fn.getreg(register) - if not register_content or register_content == "" then util.print_error("Register `" .. register .. "` is empty or invalid!") return @@ -52,18 +61,23 @@ M.run = function(macro) util.print_error("Macro is empty. Cannot run.") return end + vim.cmd.normal(vim.api.nvim_replace_termcodes(macro, true, true, true)) end -- Save macro to JSON (Raw and Escaped) M.save_macro = function(register) + local valid_registers = "[a-z0-9]" if not register or register == "" then - register = vim.fn.input("Specify a register to save from: ") + register = util.get_register_input("Specify a register to save from: ", config.default_macro_register) end - if not register or register == "" then - register = config.default_macro_register - util.print_info("No register specified. Using default `" .. config.default_macro_register .. "`.") + while not (register:match("^" .. valid_registers .. "$")) do + util.print_error( + "Invalid register: `" .. register .. "`. Register must be a single lowercase letter or number 1-9." + ) + + register = util.get_register_input("Specify a register to save from: ", config.default_macro_register) end local register_content = vim.fn.getreg(register) @@ -81,17 +95,17 @@ M.save_macro = function(register) local macro = vim.fn.keytrans(register_content) local macro_raw = base64.enc(register_content) - local macros = json.handle_json_file(config.json_file_path, "r") + local macros = json.handle_json_file(config.json_formatter, config.json_file_path, "r") if macros then table.insert(macros.macros, { name = name, content = macro, raw = macro_raw }) - json.handle_json_file(config.json_file_path, "w", macros) + json.handle_json_file(config.json_formatter, config.json_file_path, "w", macros) util.print_message("Macro `" .. name .. "` saved.") end end -- Delete macro from JSON file M.delete_macro = function() - local macros = json.handle_json_file(config.json_file_path, "r") + local macros = json.handle_json_file(config.json_formatter, config.json_file_path, "r") if not macros or not macros.macros or #macros.macros == 0 then util.print_error("No macros to delete.") return @@ -126,14 +140,14 @@ M.delete_macro = function() end table.remove(macros.macros, macro_index) - json.handle_json_file(config.json_file_path, "w", macros) + json.handle_json_file(config.json_formatter, config.json_file_path, "w", macros) util.print_message("Macro `" .. macro_name .. "` deleted.") end) end -- Select and yank macro from JSON (Raw or Escaped) M.select_and_yank_macro = function() - local macros = json.handle_json_file(config.json_file_path, "r") + local macros = json.handle_json_file(config.json_formatter, config.json_file_path, "r") if not macros or not macros.macros or #macros.macros == 0 then util.print_error("No macros to select.") return @@ -179,11 +193,23 @@ M.select_and_yank_macro = function() util.set_macro_to_register(macro_content) util.print_message("Yanked macro `" .. macro_name .. "` to clipboard.") elseif yank_option == "2" then - local target_register = vim.fn.input("Specify a register to yank the raw macro to: ") - if not target_register or target_register == "" then - target_register = config.default_macro_register - util.print_info("No register specified. Using default `" .. config.default_macro_register .. "`.") + local valid_registers = "[a-z0-9]" + local target_register = + util.get_register_input("Specify a register to yank the raw macro to: ", config.default_macro_register) + + while not (target_register:match("^" .. valid_registers .. "$")) do + util.print_error( + "Invalid register: `" + .. target_register + .. "`. Register must be a single lowercase letter or number 1-9." + ) + + target_register = util.get_register_input( + "Specify a register to yank the raw macro to: ", + config.default_macro_register + ) end + util.set_decoded_macro_to_register(encoded_content, target_register) util.print_message("Yanked raw macro `" .. macro_name .. "` into register `" .. target_register .. "`.") else diff --git a/lua/nvim-macros/json.lua b/lua/nvim-macros/json.lua index ab9a58b..8b5fd85 100644 --- a/lua/nvim-macros/json.lua +++ b/lua/nvim-macros/json.lua @@ -2,43 +2,190 @@ local util = require("nvim-macros.util") local M = {} -M.handle_json_file = function(json_file_path, mode, data) +local validate_json = function(decoded_content) + if + not decoded_content + or type(decoded_content) ~= "table" + or not decoded_content.macros + or type(decoded_content.macros) ~= "table" + then + return false + end + + for _, macro in ipairs(decoded_content.macros) do + if + type(macro) ~= "table" + or type(macro.name) ~= "string" + or macro.name == "" + or type(macro.raw) ~= "string" + or macro.raw == "" + or type(macro.content) ~= "string" + or macro.content == "" + then + return false + end + end + + return true +end + +local pretty_print_json = function(data, formatter) + local json_str = vim.fn.json_encode(data) + + if formatter == "jq" then + if vim.fn.executable("jq") == 0 then + util.print_error("jq is not installed. Falling back to default 'none'.") + return json_str + end + local cmd = "echo " .. vim.fn.shellescape(json_str) .. " | jq --monochrome-output ." + return vim.fn.system(cmd) + elseif formatter == "yq" then + if vim.fn.executable("yq") == 0 then + util.print_error("yq is not installed. Falling back to default 'none'.") + return json_str + end + local cmd = "echo " + .. vim.fn.shellescape(json_str) + .. " | yq -P --output-format=json --input-format=json --no-colors ." + return vim.fn.system(cmd) + else + return json_str + end +end + +local get_latest_backup = function(backup_dir) + local p = io.popen('ls -t "' .. backup_dir .. '"') + if p then + local latest_backup = p:read("*l") + p:close() + return latest_backup and backup_dir .. "/" .. latest_backup + else + return nil + end +end + +local restore_from_backup = function(backup_file, original_file) + if not backup_file or not original_file then + return false + end + + local restore_cmd = "cp -f '" .. backup_file .. "' '" .. original_file .. "'" + return os.execute(restore_cmd) == 0 +end + +local cleanup_old_backups = function(backup_dir, keep_last_n) + local p = io.popen('ls -t "' .. backup_dir .. '"') + if not p then + return nil + end + + local backups = {} + for filename in p:lines() do + table.insert(backups, filename) + end + p:close() + + for i = keep_last_n + 1, #backups do + local backup_to_delete = backup_dir .. "/" .. backups[i] + os.remove(backup_to_delete) + end +end + +M.handle_json_file = function(json_formatter, json_file_path, mode, data) if not json_file_path or json_file_path == "" then util.print_error("Invalid JSON file path.") return mode == "r" and { macros = {} } or nil end local file_path = json_file_path + local backup_dir = vim.fn.stdpath("data") .. "/nvim-macros/backups" + vim.fn.mkdir(backup_dir, "p") + if mode == "r" then local file = io.open(file_path, "r") if not file then - util.print_info("No JSON found. Creating new file: " .. file_path) - M.handle_json_file(json_file_path, "w", { macros = {} }) - return { macros = {} } + local latest_backup = get_latest_backup(backup_dir) + if latest_backup then + if restore_from_backup(latest_backup, file_path) then + util.print_info("No JSON found. Restored from the most recent backup.") + file = io.open(file_path, "r") + end + else + util.print_info("No JSON found. Creating new file: " .. file_path) + file = io.open(file_path, "w") + if file then + local content = vim.fn.json_encode({ macros = {} }) + file:write(content) + file:close() + return { macros = {} } + else + util.print_error("Failed to create new file: " .. file_path) + return nil + end + end end - local content = file:read("*a") - file:close() - if not content or content == "" then - util.print_info("File is empty. Initializing with default structure.") - return { macros = {} } - else - local status, decoded_content = pcall(vim.fn.json_decode, content) - if not status or not decoded_content then - util.print_error("Invalid JSON content: " .. file_path) - util.print_info("Correct the JSON manually or delete the file to reset.") - return nil + + if file then + local content = file:read("*a") + file:close() + + if not content or content == "" then + local latest_backup = get_latest_backup(backup_dir) + if latest_backup then + if restore_from_backup(latest_backup, file_path) then + util.print_info("File is empty. Restored from most recent backup.") + return M.handle_json_file(json_formatter, json_file_path, mode, data) + end + else + util.print_info("File is empty. Initializing with default structure.") + return { macros = {} } + end + end + + if content then + local status, decoded_content = pcall(vim.fn.json_decode, content) + if status and validate_json(decoded_content) then + return decoded_content + else + util.print_error("Invalid JSON content. Attempting to restore from backup.") + local latest_backup = get_latest_backup(backup_dir) + if latest_backup and restore_from_backup(latest_backup, file_path) then + util.print_info("Successfully restored from backup.") + return M.handle_json_file(json_formatter, json_file_path, mode, data) + else + util.print_error("Failed to restore from backup. Manual check required.") + return nil + end + end end - return decoded_content end elseif mode == "w" then - local file = io.open(file_path, "w") + local temp_file_path = file_path .. ".tmp" + local backup_file_path = backup_dir .. "/" .. os.date("%Y%m%d%H%M%S") .. "_macros.json.bak" + + local file = io.open(temp_file_path, "w") if not file then - util.print_error("Unable to open JSON file for writing: " .. file_path) + util.print_error("Unable to write to the file.") return nil end - local content = vim.fn.json_encode(data) + + local content = (json_formatter == "jq" or json_formatter == "yq") and pretty_print_json(data, json_formatter) + or vim.fn.json_encode(data) file:write(content) file:close() + + if not os.rename(file_path, backup_file_path) or not os.rename(temp_file_path, file_path) then + util.print_error("Failed to update the macros file. Attempting to restore from the most recent backup.") + local latest_backup = get_latest_backup(backup_dir) + if latest_backup and restore_from_backup(latest_backup, file_path) then + util.print_info("Successfully restored from backup.") + else + util.print_error("Failed to restore from backup. Manual check required.") + end + else + os.execute("cp -f '" .. file_path .. "' '" .. backup_file_path .. "'") + cleanup_old_backups(backup_dir, 3) + end else util.print_error("Invalid mode: '" .. mode .. "'. Use 'r' or 'w'.") end diff --git a/lua/nvim-macros/util.lua b/lua/nvim-macros/util.lua index 3658a59..b3cf5b9 100644 --- a/lua/nvim-macros/util.lua +++ b/lua/nvim-macros/util.lua @@ -29,6 +29,26 @@ M.get_default_register = function() return '"' end +-- Get register input +M.get_register_input = function(prompt, default_register) + local valid_registers = "[a-z0-9]" + local register = vim.fn.input(prompt) + + while not (register:match("^" .. valid_registers .. "$") or register == "") do + M.print_error( + "Invalid register: `" .. register .. "`. Register must be a single lowercase letter or number 1-9." + ) + register = vim.fn.input(prompt) + end + + if register == "" then + register = default_register + M.print_info("No register specified. Using default `" .. default_register .. "`.") + end + + return register +end + -- Decode and set macro to register M.set_decoded_macro_to_register = function(encoded_content, target_register) if not encoded_content or encoded_content == "" then