diff --git a/README.md b/README.md index c960f5d..003d58c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,47 @@ # file-renamer.nvim + Rename files like text inside NeoVIM. + +This plugin lets you edit filenames directly in a buffer and apply the changes to the file system. + +This plugin is inspired by and based on the functionality of +[qpkorrs vim-renamer](https://github.com/qpkorr/vim-renamer) though it does not share any code with +it. + + +## Features + +- Edit filenames in a buffer +- Navigate directories (``) +- Support for nested paths (`file -> dir/file`) +- Automatically creates missing directories +- Safe renaming (handles swaps like `a <-> b`) +- Skips invalid or conflicting renames + +## Installation (lazy.nvim) + +```lua +return { + "tiyn/file-renamer.nvim", +} +```` + +## Usage + +First start the renaming buffer with the following command. + +```vim +:Rename +``` + +Change the files as needed. +Then apply the changes using the following command. + +```vim +:Ren +``` + +## Notes + +* Do not change the number of lines +* Lines starting with `#` are ignored diff --git a/doc/file-renamer.txt b/doc/file-renamer.txt new file mode 100644 index 0000000..2cffd78 --- /dev/null +++ b/doc/file-renamer.txt @@ -0,0 +1,118 @@ +*file-renamer.txt* Rename files like text in Neovim + +INTRODUCTION *file-renamer* {{{1 +Rename multiple files by editing their names directly in a Neovim buffer. + +This plugin displays files in the current directory and allows you to +modify their names using normal editing commands. Once you're done, +apply the changes to the filesystem. + +DESCRIPTION *file-renamer-description* {{{1 +Renaming files individually is simple, but renaming many files at once, +especially with common text transformations, can be tedious. + +file-renamer solves this by presenting a directory listing inside a buffer. +You can freely edit filenames as text. When ready, apply the changes and +all valid renames will be executed. + +Features: +- Edit filenames directly in a buffer +- Supports moving files into subdirectories (e.g. "file -> dir/file") +- Automatically creates missing directories +- Safe two-pass renaming (handles swaps like "a -> b", "b -> a") +- Skips invalid or conflicting renames +- Prevents overwriting existing files outside rename set +- No temporary file leftovers on failure + +USAGE *file-renamer-usage* {{{1 +:Renamer *:Renamer* + Start renaming in the current working directory. + +Inside the buffer: + +- Edit filenames freely +- Press on a directory to enter it +- Press on "# ../" to go up one level + +To apply changes: + +:Ren + Perform the rename operation. + +The buffer will refresh automatically after renaming. + +RENAMING RULES *file-renamer-rules* {{{1 +- Lines starting with "#" are ignored +- Directories must end with "/" +- You must not change the number of entries +- Duplicate target names are skipped +- Existing files outside the rename set are not overwritten +- Relative paths (e.g. "../dir/file") are supported +- New directories are created automatically + +Examples: + +Swap files: + a -> b + b -> a + +Move into subdirectory: + file.txt -> folder/file.txt + +Move into parent directory: + file.txt -> ../file.txt + +Invalid rename (skipped): + a -> existing_file + +Invalid rename in parent directory (skipped): + a -> ../existing_file + +INSTALLATION *file-renamer-install* {{{1 +Using lazy.nvim: + + { + dir = "/path/to/file-renamer", + name = "file-renamer", + lazy = false, + } + +Then restart Neovim. + +KEY MAPPINGS *file-renamer-mappings* {{{1 +Default mappings inside the buffer: + + Enter directory or go up ("# ../") + +You can define your own mappings for starting: + + vim.keymap.set("n", "r", ":Renamer") + +BEHAVIOUR *file-renamer-behaviour* {{{1 +file-renamer performs renaming in two phases: + +1. Files are renamed to temporary names +2. Files are renamed to their final destinations + +If a rename fails: +- It is skipped +- The original file is restored +- No temporary files are left behind + +This ensures safe and predictable behaviour. + +LIMITATIONS *file-renamer-limitations* {{{1 +- Does not yet support hidden file toggling +- No preview mode (:RenTest equivalent) +- No original file split window +- No per-file highlighting of modified names (planned) + +CHANGELOG *file-renamer-changelog* {{{1 +1.0.0 - Initial release + - Basic rename buffer + - Directory navigation + - Two-pass safe renaming + - Swap support (a <-> b) + - Directory creation support + - Conflict detection and skipping + - Rollback on failure diff --git a/lua/file-renamer/init.lua b/lua/file-renamer/init.lua new file mode 100644 index 0000000..f86e9fc --- /dev/null +++ b/lua/file-renamer/init.lua @@ -0,0 +1,217 @@ +local M = {} + +local hashes = "### " + +local header = { + "Renamer: change names then give command :Ren\n", +} + +function M.start(start_dir, cursor_line) + vim.cmd("enew") + + local cwd = start_dir or vim.fn.getcwd() + vim.b.rename_cwd = cwd + + local files = vim.fn.readdir(cwd) + + local dirs, normal_files = {}, {} + + for _, f in ipairs(files) do + if not f:match("^%.") then + local full = cwd .. "/" .. f + if vim.fn.isdirectory(full) == 1 then + dirs[#dirs + 1] = f + else + normal_files[#normal_files + 1] = f + end + end + end + + table.sort(dirs) + table.sort(normal_files) + + local ordered = {} + for _, d in ipairs(dirs) do ordered[#ordered + 1] = d end + for _, f in ipairs(normal_files) do ordered[#ordered + 1] = f end + + vim.b.rename_original_names = ordered + + local lines = {} + + for _, line in ipairs(header) do + lines[#lines + 1] = hashes .. line:gsub("\n", "") + end + + lines[#lines + 1] = "" + lines[#lines + 1] = hashes .. "Currently editing: " .. cwd .. "/*" + lines[#lines + 1] = "# ../" + + for _, d in ipairs(dirs) do lines[#lines + 1] = d .. "/" end + for _, f in ipairs(normal_files) do lines[#lines + 1] = f end + + vim.api.nvim_buf_set_lines(0, 0, -1, false, lines) + + vim.bo.buftype = "nofile" + vim.bo.bufhidden = "wipe" + vim.bo.swapfile = false + + vim.keymap.set("n", "", M.enter, { buffer = true, silent = true }) + + if cursor_line then + vim.api.nvim_win_set_cursor(0, { cursor_line, 0 }) + else + vim.api.nvim_win_set_cursor(0, { #header + 3, 0 }) + end + + vim.cmd("syntax clear") + vim.cmd([[syntax match RenameComment "^#.*"]]) + vim.cmd([[syntax match RenameDirectory "^[^#].*/$"]]) + vim.cmd([[syntax match RenameFile "^[^#].*[^/]$"]]) + + vim.cmd("highlight default link RenameComment Comment") + vim.cmd("highlight default link RenameDirectory Constant") + vim.cmd("highlight default link RenameFile Function") +end + +function M.restore_buffer() + local cwd = vim.b.rename_cwd + local original = vim.b.rename_original_names or {} + + local dirs, files = {}, {} + + for _, name in ipairs(original) do + local full = cwd .. "/" .. name + if vim.fn.isdirectory(full) == 1 then + dirs[#dirs + 1] = name + else + files[#files + 1] = name + end + end + + table.sort(dirs) + table.sort(files) + + local lines = {} + + for _, line in ipairs(header) do + lines[#lines + 1] = hashes .. line:gsub("\n", "") + end + + lines[#lines + 1] = "" + lines[#lines + 1] = hashes .. "Currently editing: " .. cwd .. "/*" + lines[#lines + 1] = "# ../" + + for _, d in ipairs(dirs) do lines[#lines + 1] = d .. "/" end + for _, f in ipairs(files) do lines[#lines + 1] = f end + + vim.api.nvim_buf_set_lines(0, 0, -1, false, lines) +end + +function M.enter() + local line = vim.api.nvim_get_current_line() + local cwd = vim.b.rename_cwd + + if line == "# ../" then + M.start(vim.fn.fnamemodify(cwd, ":h")) + return + end + + if line:sub(-1) == "/" then + M.start(cwd .. "/" .. line:gsub("/$", "")) + end +end + +function M.perform_rename() + local cwd = vim.b.rename_cwd + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + + local new_names = {} + for _, line in ipairs(lines) do + if not line:match("^#") and line ~= "" then + new_names[#new_names + 1] = vim.trim(line):gsub("/$", "") + end + end + + local old_names = vim.b.rename_original_names or {} + + if #old_names ~= #new_names then + print("Mismatch in file count!") + M.restore_buffer() + return + end + + local target_count = {} + for _, new in ipairs(new_names) do + target_count[new] = (target_count[new] or 0) + 1 + end + + local target_set = {} + for _, new in ipairs(new_names) do + target_set[vim.fn.simplify(cwd .. "/" .. new)] = true + end + + local ops = {} + + for i, old in ipairs(old_names) do + local new = new_names[i] + + if old ~= new then + if target_count[new] ~= 1 then + print("Skipping duplicate target:", new) + + else + local new_path = vim.fn.simplify(cwd .. "/" .. new) + + local exists = vim.fn.filereadable(new_path) == 1 + or vim.fn.isdirectory(new_path) == 1 + + if exists and not target_set[new_path] then + print("Target exists, skipping:", new) + else + ops[#ops + 1] = { + index = i, + old = old, + new = new, + old_path = cwd .. "/" .. old, + new_path = new_path, + } + end + end + end + end + + for _, op in ipairs(ops) do + local new_dir = vim.fn.fnamemodify(op.new_path, ":h") + if vim.fn.isdirectory(new_dir) == 0 then + vim.fn.mkdir(new_dir, "p") + end + + op.tmp = cwd .. "/" .. op.index .. "_RENAMER_TMP_" + + local ok = os.rename(op.old_path, op.tmp) + if not ok then + print("Failed temp rename:", op.old) + op.failed = true + end + end + + for _, op in ipairs(ops) do + if not op.failed then + local ok = os.rename(op.tmp, op.new_path) + + if not ok then + print("Failed final rename:", op.new) + + local rollback_ok = os.rename(op.tmp, op.old_path) + if not rollback_ok then + print("CRITICAL: rollback failed for:", op.old) + end + end + end + end + + print("Rename done") + M.start(cwd) +end + +return M diff --git a/plugin/file-renamer.lua b/plugin/file-renamer.lua new file mode 100644 index 0000000..ddec954 --- /dev/null +++ b/plugin/file-renamer.lua @@ -0,0 +1,7 @@ +vim.api.nvim_create_user_command("Renamer", function() + require("file-renamer").start() +end, {}) + +vim.api.nvim_create_user_command("Ren", function() + require("file-renamer").perform_rename() +end, {})