diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9631a28 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +trim_trailing_whitespace = true + +[*.lua] +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +max_line_length = 120 diff --git a/README.md b/README.md index 51afd83..ac778d8 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # Hex.nvim + ![Lua](https://img.shields.io/badge/Made%20with%20Lua-blueviolet.svg?style=for-the-badge&logo=lua) Hex editing done right. @@ -7,65 +8,102 @@ Shift address lines around, edit and delete bytes, it will all work itself out o ![demo](https://user-images.githubusercontent.com/16624558/211962886-f5e67052-03d8-41c2-844f-720550c935b4.gif) -## Install +## Installation + ```lua -{ 'RaafatTurki/hex.nvim' } +{ "RaafatTurki/hex.nvim" } ``` + This plugin makes use of the `xxd` utility by default, make sure it's on `$PATH`: - `xxd-standalone` from aur - compile from [source](https://github.com/vim/vim/tree/master/src/xxd) - install vim (it comes with it) +> [!NOTE] +> +> No setup or require needed; simply place it in your runtimepath to use. -## Setup -```lua -require 'hex'.setup() -``` +## Usage -## Use ```lua -require 'hex'.dump() -- switch to hex view -require 'hex'.assemble() -- go back to normal view -require 'hex'.toggle() -- switch back and forth +require("hex").dump() -- switch to hex view +require("hex").assemble() -- go back to normal view +require("hex").toggle() -- switch back and forth ``` -or their vim cmds + +Or their Vim cmds: + ``` -:HexDump -:HexAssemble -:HexToggle +:Hex dump +:Hex assemble +:Hex toggle ``` -any file opens in hex view if opened with `-b`: + +Any file opens in hex view if opened with `-b`: + ```bash nvim -b file nvim -b file1 file2 ``` -## Config -```lua --- defaults -require 'hex'.setup { +## Configuration - -- cli command used to dump hex data - dump_cmd = 'xxd -g 1 -u', +Hex.nvim uses a global table for configuration. User options are merged +with the default table prior to initialization unless `vim.g.hex.default` +is set to false. Below are the available options and their default values: - -- cli command used to assemble from hex data - assemble_cmd = 'xxd -r', - +```lua +vim.g.hex = { + default = true, + cmd = { + -- cli command used to dump hex data + dump = "xxd -g 1 -u", + -- cli command used to assemble from hex data + revert = "xxd -r", + }, -- function that runs on BufReadPre to determine if it's binary or not - is_file_binary_pre_read = function() + checkbin_pre = function() -- logic that determines if a buffer contains binary data or not -- must return a bool + local binary_ext = { + "bin", + "dll", + "exe", + "jpg", + "jpeg", + "png", + "out", + } + -- only work on normal buffers + if vim.bo.ft ~= "" then return false end + -- check -b flag + if vim.bo.binary then return true end + local ext = vim.fn.expand("%:e") + if vim.tbl_contains(binary_ext, ext) then return true end + return false end, - -- function that runs on BufReadPost to determine if it's binary or not - is_file_binary_post_read = function() + checkbin_post = function() -- logic that determines if a buffer contains binary data or not -- must return a bool + local encoding = (vim.bo.fenc ~= "" and vim.bo.fenc) or vim.o.enc + if encoding ~= "utf-8" then return true end + return false end, + keymaps = true, + prettify = { + enable = true, + unicode = true, + hl = { + border = "Special", + middle = "Special", + } + } } ``` ## Plans + - [ ] Implement pagination - [x] Implement auto bin detection - [ ] Transform cursor position across views diff --git a/lua/hex.lua b/lua/hex.lua deleted file mode 100644 index 0afb069..0000000 --- a/lua/hex.lua +++ /dev/null @@ -1,98 +0,0 @@ -local u = require 'hex.utils' -local augroup_hex_editor = vim.api.nvim_create_augroup('hex_editor', { clear = true }) - -local M = {} - -M.cfg = { - dump_cmd = 'xxd -g 1 -u', - assemble_cmd = 'xxd -r', - is_file_binary_pre_read = function() - local binary_ext = { 'out', 'bin', 'png', 'jpg', 'jpeg', 'exe', 'dll' } - -- only work on normal buffers - if vim.bo.ft ~= "" then return false end - -- check -b flag - if vim.bo.bin then return true end - -- check ext within binary_ext - local filename = vim.fn.expand('%:t') - local ext = vim.fn.expand('%:e') - if vim.tbl_contains(binary_ext, ext) then return true end - -- none of the above - return false - end, - is_file_binary_post_read = function() - local encoding = (vim.bo.fenc ~= '' and vim.bo.fenc) or vim.o.enc - if encoding ~= 'utf-8' then return true end - return false - end, -} - -M.dump = function() - if not vim.b.hex then - u.dump_to_hex(M.cfg.dump_cmd) - else - vim.notify('already dumped!', vim.log.levels.WARN) - end -end - -M.assemble = function() - if vim.b.hex then - u.assemble_from_hex(M.cfg.assemble_cmd) - else - vim.notify('already assembled!', vim.log.levels.WARN) - end -end - -M.toggle = function() - if not vim.b.hex then - M.dump() - else - M.assemble() - end -end - -local setup_auto_cmds = function() - vim.api.nvim_create_autocmd({ 'BufReadPre' }, { group = augroup_hex_editor, callback = function() - if M.cfg.is_file_binary_pre_read() then - vim.b.hex = true - end - end }) - - vim.api.nvim_create_autocmd({ 'BufReadPost' }, { group = augroup_hex_editor, callback = function() - if vim.b.hex then - u.dump_to_hex(M.cfg.dump_cmd) - elseif M.cfg.is_file_binary_post_read() then - vim.b.hex = true - u.dump_to_hex(M.cfg.dump_cmd) - end - end }) - - vim.api.nvim_create_autocmd({ 'BufWritePre' }, { group = augroup_hex_editor, callback = function() - if vim.b.hex then - u.begin_patch_from_hex(M.cfg.assemble_cmd) - end - end }) - - vim.api.nvim_create_autocmd({ 'BufWritePost' }, { group = augroup_hex_editor, callback = function() - if vim.b.hex then - u.finish_patch_from_hex(M.cfg.dump_cmd) - end - end }) -end - -M.setup = function(args) - M.cfg = vim.tbl_deep_extend("force", M.cfg, args or {}) - - local dump_program = vim.fn.split(M.cfg.dump_cmd)[1] - local assemble_program = vim.fn.split(M.cfg.assemble_cmd)[1] - - if not u.is_program_executable(dump_program) then return end - if not u.is_program_executable(assemble_program) then return end - - vim.api.nvim_create_user_command('HexDump', M.dump, {}) - vim.api.nvim_create_user_command('HexAssemble', M.assemble, {}) - vim.api.nvim_create_user_command('HexToggle', M.toggle, {}) - - setup_auto_cmds() -end - -return M diff --git a/lua/hex/health.lua b/lua/hex/health.lua new file mode 100644 index 0000000..a45088a --- /dev/null +++ b/lua/hex/health.lua @@ -0,0 +1,12 @@ +local health = {} + +function health.check() + vim.health.start("Dependencies ~") + if vim.fn.executable("xxd") == 0 then + vim.health.error("'xxd'" .. " is not installed") + else + vim.health.ok("'xxd'" .. " is installed") + end +end + +return health diff --git a/lua/hex/init.lua b/lua/hex/init.lua new file mode 100644 index 0000000..6c671da --- /dev/null +++ b/lua/hex/init.lua @@ -0,0 +1,138 @@ +local config = vim.g.hex +local render = require("hex.render") + +local M = {} + +function M.toggle() + if not vim.b.hex then + M.dump() + else + M.revert() + end +end + +function M.dump() + if vim.b.hex then + vim.notify("Already dumped.", vim.log.levels.WARN) + return + end + M.hex_dump() + render.prettify() +end + +function M.revert() + if not vim.b.hex then + vim.notify("Already reverted.", vim.log.levels.WARN) + return + end + M.hex_revert() +end + +local function get_undo_path() + local buf = vim.api.nvim_buf_get_name(0) + local resolved_fname = vim.fs.basename(vim.fn.undofile(buf)) + local undofile = vim.fs.joinpath(vim.fn.stdpath("state"), "hex", resolved_fname) + return undofile +end + +local function load_undo() + local undofile = get_undo_path() + if vim.fn.filereadable(undofile) == 1 then + vim.cmd("rundo " .. vim.fn.fnameescape(undofile)) + end +end + +function M.save_undo() + local undofile = get_undo_path() + local undodir = vim.fs.dirname(undofile) + if not vim.uv.fs_stat(undodir) then + vim.fn.mkdir(undodir, "p") + end + vim.cmd("wundo! " .. vim.fn.fnameescape(undofile)) +end + +local function xxd_common(cmd, patch) + local xxd = {} + for i in string.gmatch(cmd, "%S+") do + table.insert(xxd, i) + end + local text + local obj + if cmd == config.cmd.dump then + table.insert(xxd, vim.api.nvim_buf_get_name(0)) + obj = vim.system(xxd, { text = true }):wait() + elseif cmd == config.cmd.revert then + text = vim.api.nvim_buf_get_lines(0, 0, -1, false) + obj = vim.system(xxd, { stdin = text }):wait() + end + local lines = vim.split(obj.stdout, "\n", { trimempty = true }) + local ul = vim.o.undolevels + if not patch then + vim.o.undolevels = -1 + end + vim.api.nvim_buf_set_lines(0, 0, -1, false, lines) + vim.o.undolevels = ul + vim.bo.modified = false +end + +function M.hex_dump() + vim.bo.binary = true + xxd_common(config.cmd.dump, false) + vim.b.hex_ft = vim.bo.filetype + vim.bo.filetype = "xxd" + vim.b.hex = true + M.detach_lsp() + load_undo() + local str = vim.api.nvim_buf_get_lines(0, 0, 1, false)[1] + vim.b.hex_offset = string.find(str, ":", 1, true) + local groupsize = tonumber(config.cmd.dump:match("%-g(%d)%s") or + config.cmd.dump:match("%-g%s(%d)%s")) + if groupsize == 1 then + vim.b.hex_byte_separator = vim.b.hex_offset + 25 + vim.b.hex_limit = 47 + elseif groupsize == 2 then + vim.b.hex_byte_separator = vim.b.hex_offset + 21 + vim.b.hex_limit = 39 + elseif groupsize == 4 then + vim.b.hex_byte_separator = vim.b.hex_offset + 19 + vim.b.hex_limit = 35 + elseif groupsize == 8 then + vim.b.hex_byte_separator = vim.b.hex_offset + 18 + vim.b.hex_limit = 33 + end +end + +function M.hex_revert() + xxd_common(config.cmd.revert, false) + vim.bo.filetype = vim.b.hex_ft + vim.b.hex = false +end + +local undofile = vim.o.undofile + +function M.patch_start() + vim.b.hex_curpos = vim.api.nvim_win_get_cursor(0) + vim.o.undofile = false + if not vim.b.hex_did_undoredo then + vim.cmd.undojoin() + else + vim.b.hex_did_undoredo = false + end + xxd_common(config.cmd.revert, true) +end + +function M.patch_finish() + xxd_common(config.cmd.dump, true) + vim.api.nvim_win_set_cursor(0, vim.b.hex_curpos) + vim.o.undofile = undofile + render.prettify() +end + +function M.detach_lsp() + local attached_servers = vim.lsp.get_clients({ bufnr = vim.api.nvim_get_current_buf() }) + for _, server in ipairs(attached_servers) do + server:stop() + end +end + +return M diff --git a/lua/hex/render.lua b/lua/hex/render.lua new file mode 100644 index 0000000..2108071 --- /dev/null +++ b/lua/hex/render.lua @@ -0,0 +1,41 @@ +local config = vim.g.hex + +local M = {} + +local ns = vim.api.nvim_create_namespace("hex_renderer") + +function M.prettify() + if config.prettify.enable == false then return end + local groupsize = tonumber(config.cmd.dump:match("%-g(%d)%s") or config.cmd.dump:match("%-g%s(%d)%s")) + if not groupsize then return end + + vim.api.nvim_buf_clear_namespace(0, ns, 0, -1) + local char + if config.prettify.unicode == true then + char = "│" + else + char = "|" + end + + local last_line = vim.api.nvim_buf_line_count(0) - 1 + for lnum = 0, last_line do + vim.api.nvim_buf_set_extmark(0, ns, lnum, vim.b.hex_offset, { + virt_text = { { char, config.prettify.hl.border } }, + virt_text_pos = "inline", + }) + end + for lnum = 0, last_line do + vim.api.nvim_buf_set_extmark(0, ns, lnum, vim.b.hex_byte_separator, { + virt_text = { { char .. " ", config.prettify.hl.middle } }, + virt_text_pos = "inline", + }) + end + for lnum = 0, last_line do + vim.api.nvim_buf_set_extmark(0, ns, lnum, vim.b.hex_offset + vim.b.hex_limit + 2, { + virt_text = { { char, config.prettify.hl.border } }, + virt_text_pos = "overlay", + }) + end +end + +return M diff --git a/lua/hex/utils.lua b/lua/hex/utils.lua deleted file mode 100644 index b7a8485..0000000 --- a/lua/hex/utils.lua +++ /dev/null @@ -1,56 +0,0 @@ -local M = {} - -function M.drop_undo_history() - local undolevels = vim.o.undolevels - vim.o.undolevels = -1 - vim.cmd [[exe "normal a \\"]] - vim.o.undolevels = undolevels -end - -function M.dump_to_hex(hex_dump_cmd) - vim.bo.bin = true - vim.b['hex'] = true - vim.cmd([[%! ]] .. hex_dump_cmd .. " \"" .. vim.fn.expand('%:p') .. "\"") - vim.b.hex_ft = vim.bo.ft - vim.bo.ft = 'xxd' - M.drop_undo_history() - M.dettach_all_lsp_clients_from_current_buf() - vim.bo.mod = false -end - -function M.assemble_from_hex(hex_assemble_cmd) - vim.cmd([[%! ]] .. hex_assemble_cmd) - vim.bo.ft = vim.b.hex_ft - M.drop_undo_history() - vim.bo.mod = false - vim.b['hex'] = false -end - -function M.begin_patch_from_hex(hex_assemble_cmd) - vim.b.hex_cur_pos = vim.fn.getcurpos() - vim.cmd([[%! ]] .. hex_assemble_cmd) -end - -function M.finish_patch_from_hex(hex_dump_cmd) - vim.cmd([[%! ]] .. hex_dump_cmd) - vim.fn.setpos('.', vim.b.hex_cur_pos) - vim.bo.mod = true -end - -function M.is_program_executable(program) - if vim.fn.executable(program) == 1 then - return true - else - vim.notify(program .. " is not installed on this system, aborting!", vim.log.levels.WARN) - return false - end -end - -function M.dettach_all_lsp_clients_from_current_buf() - local attached_servers = vim.lsp.get_clients({ bufnr = vim.api.nvim_get_current_buf() }) - for _, attached_server in ipairs(attached_servers) do - attached_server.stop() - end -end - -return M diff --git a/plugin/hex.lua b/plugin/hex.lua new file mode 100644 index 0000000..3a08681 --- /dev/null +++ b/plugin/hex.lua @@ -0,0 +1,220 @@ +if vim.g.loaded_hex == 1 then return end +vim.g.loaded_hex = 1 + +if vim.fn.executable("xxd") == 0 then + vim.notify( + "Required executable `xxd` not found.", + vim.log.levels.ERROR, + { title = "hex" }) + return +end + +vim.api.nvim_create_user_command("Hex", function(opts) + if opts.fargs[2] ~= nil then + vim.notify( + "Too many arguments.", + vim.log.levels.ERROR, + { title = "hex" } + ) + return + end + local arg = opts.fargs[1] + if arg == "dump" then + require("hex").dump() + elseif arg == "revert" then + require("hex").revert() + elseif arg == "toggle" then + require("hex").toggle() + else + vim.notify( + "Hex accepts the following arguments:\n" .. + " - dump\n" .. + " - revert\n" .. + " - toggle", + vim.log.levels.ERROR, + { title = "hex" } + ) + end +end, { + nargs = "+", + complete = function() + -- TODO: needs to complete properly + return { + "dump", + "revert", + "toggle", + } + end +}) + +-- Configuration {{{ +local DEFAULTS = { + default = true, + cmd = { + dump = "xxd -g 1 -u", + revert = "xxd -r", + }, + extensions = { + "bin", + "dll", + "exe", + "out", + }, + checkbin_pre = function() + -- only work on normal buffers + if vim.bo.ft ~= "" then return false end + -- check -b flag + if vim.bo.binary then return true end + local ext = vim.fn.expand("%:e") + if vim.tbl_contains(vim.g.hex.extensions, ext) then return true end + return false + end, + checkbin_post = function() + local encoding = (vim.bo.fenc ~= "" and vim.bo.fenc) or vim.o.enc + if encoding ~= "utf-8" then return true end + return false + end, + keymaps = true, + prettify = { + enable = true, + unicode = true, + hl = { + border = "Special", + middle = "Special", + } + } +} + +if vim.g.hex == nil or vim.g.hex.default ~= false then + vim.g.hex = vim.tbl_deep_extend( + "force", + DEFAULTS, + vim.g.hex or {} + ) +end +-- }}} + +local config = vim.g.hex + +-- Keymaps {{{ +vim.keymap.set("n", "(HexUndo)", function() + if vim.b.hex then + vim.b.hex_did_undoredo = true + end + vim.cmd.undo() +end, { silent = true }) +vim.keymap.set("n", "(HexRedo)", function() + if vim.b.hex then + vim.b.hex_did_undoredo = true + end + vim.cmd.redo() +end, { silent = true }) + +if config.keymaps == true then + vim.keymap.set("n", "u", "(HexUndo)", { desc = "Undo one change" }) + vim.keymap.set("n", "", "(HexRedo)", { desc = "Redo one change which was undone" }) +end +-- }}} + +-- Autocommands {{{ +local group = vim.api.nvim_create_augroup("Hex", { clear = true }) + +vim.api.nvim_create_autocmd({ "BufReadPre" }, { + group = group, + callback = function() + if config.checkbin_pre() then + vim.b.hex = true + end + end +}) + +vim.api.nvim_create_autocmd({ "BufReadPost" }, { + group = group, + callback = function() + if vim.b.hex or config.checkbin_post() then + vim.b.hex = true + require("hex").hex_dump() + require("hex.render").prettify() + end + end +}) + +vim.api.nvim_create_autocmd({ "BufWritePre" }, { + group = group, + callback = function() + if vim.b.hex then + require("hex").patch_start() + end + end +}) + +vim.api.nvim_create_autocmd({ "BufWritePost" }, { + group = group, + callback = function() + if vim.b.hex then + require("hex").patch_finish() + require("hex").save_undo() + end + end +}) + +vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI" }, { + group = group, + callback = function() + if vim.b.hex then + local EDIT_LIMIT_LEFT = vim.b.hex_offset + 1 + local EDIT_LIMIT_RIGHT = vim.b.hex_offset + vim.b.hex_limit + local row, col = unpack(vim.api.nvim_win_get_cursor(0)) + if col < EDIT_LIMIT_LEFT then + vim.api.nvim_win_set_cursor(0, { row, EDIT_LIMIT_LEFT }) + elseif col > EDIT_LIMIT_RIGHT then + vim.api.nvim_win_set_cursor(0, { row, EDIT_LIMIT_RIGHT }) + end + end + end +}) + +vim.api.nvim_create_autocmd("ModeChanged", { + group = group, + pattern = "*V", + callback = function() + if vim.b.hex then + vim.notify( + "Linewise Visual mode is not allowed in `xxd` files.", + vim.log.levels.ERROR, + { title = "hex" }) + local keys = vim.api.nvim_replace_termcodes("", true, false, true) + vim.api.nvim_feedkeys(keys, "n", false) + end + end +}) + +vim.api.nvim_create_autocmd({ "InsertCharPre" }, { + callback = function() + if vim.b.hex then + if vim.tbl_contains({ + "G", "H", "I", "J", + "K", "L", "M", "N", + "O", "P", "Q", "R", + "S", "T", "U", "V", + "W", "X", "Y", "Z", + "g", "h", "i", "j", + "k", "l", "m", "n", + "o", "p", "q", "r", + "s", "t", "u", "v", + "w", "x", "y", "z", + "~", "`", "!", "@", + "#", "$", "%", "^", + "&", "*", "(", ")", + "-", "_", "=", '+', + "[", "{", "]", "}", + ";", ":", "'", '"', + "\\", "|", ",", "<", + ".", ">", "/", "?", + }, vim.v.char) then + vim.v.char = "" + end + end + end +}) +-- }}}