1.0.0: added initial functionality

This commit is contained in:
2026-03-24 02:42:09 +01:00
parent aa8700e72d
commit 09eb64474b
4 changed files with 387 additions and 0 deletions

View File

@@ -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
View 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
View 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
View 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, {})