mirror of
https://github.com/tiyn/file-renamer.nvim.git
synced 2026-04-17 02:54:49 +02:00
1.0.0: added initial functionality
This commit is contained in:
45
README.md
45
README.md
@@ -1,2 +1,47 @@
|
|||||||
# file-renamer.nvim
|
# file-renamer.nvim
|
||||||
|
|
||||||
Rename files like text inside NeoVIM.
|
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 (`<CR>`)
|
||||||
|
- 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
|
||||||
|
|||||||
118
doc/file-renamer.txt
Normal file
118
doc/file-renamer.txt
Normal file
@@ -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 <Enter> on a directory to enter it
|
||||||
|
- Press <Enter> 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:
|
||||||
|
|
||||||
|
<CR> Enter directory or go up ("# ../")
|
||||||
|
|
||||||
|
You can define your own mappings for starting:
|
||||||
|
|
||||||
|
vim.keymap.set("n", "<leader>r", ":Renamer<CR>")
|
||||||
|
|
||||||
|
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
|
||||||
217
lua/file-renamer/init.lua
Normal file
217
lua/file-renamer/init.lua
Normal file
@@ -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", "<CR>", 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
|
||||||
7
plugin/file-renamer.lua
Normal file
7
plugin/file-renamer.lua
Normal file
@@ -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, {})
|
||||||
Reference in New Issue
Block a user