Skip to content

Commit a9f2140

Browse files
authored
feat (fs_actions): add brace expansion for creating files / folders (#661)
closes #647
1 parent 73a90f6 commit a9f2140

File tree

4 files changed

+229
-55
lines changed

4 files changed

+229
-55
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,12 +214,13 @@ use {
214214
--["Z"] = "expand_all_nodes",
215215
["a"] = {
216216
"add",
217+
-- this command supports BASH style brace expansion ("x{a,b,c}" -> xa,xb,xc). see `:h neo-tree-file-actions` for details
217218
-- some commands may take optional config options, see `:h neo-tree-mappings` for details
218219
config = {
219220
show_path = "none" -- "none", "relative", "absolute"
220221
}
221222
},
222-
["A"] = "add_directory", -- also accepts the optional config.show_path option like "add".
223+
["A"] = "add_directory", -- also accepts the optional config.show_path option like "add". this also supports BASH style brace expansion.
223224
["d"] = "delete",
224225
["r"] = "rename",
225226
["y"] = "copy_to_clipboard",

doc/neo-tree.txt

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -243,9 +243,21 @@ a = add: Create a new file OR directory. Add a `/` to the
243243
`"absolute"`: is the full path to the current
244244
directory.
245245

246+
The file path also supports BASH style brace
247+
expansion. sequence style ("{00..05..2}") as well
248+
as nested braces. Here are some examples how this
249+
expansion works.
250+
251+
"x{a..e..2}" : "xa", "xc", "xe"
252+
"file.txt{,.bak}" : "file.txt", "file.txt.bak"
253+
"./{a,b}/{00..02}.lua" : "./a/00.lua", "./a/01.lua",
254+
"./a/02.lua", "./b/00.lua",
255+
"./b/01.lua", "./b/02.lua"
256+
246257
A = add_directory: Create a new directory, in this mode it does not
247-
need to end with a `/`. Also accepts
248-
`config.show_path` options
258+
need to end with a `/`. The path also supports
259+
BASH style brace expansion as explained in `add`
260+
command. Also accepts `config.show_path` options
249261

250262
d = delete: Delete the selected file or directory.
251263
Supports visual selection.~

lua/neo-tree/sources/filesystem/lib/fs_actions.lua

Lines changed: 56 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -276,31 +276,33 @@ M.create_directory = function(in_directory, callback, using_root_directory)
276276
using_root_directory = false
277277
end
278278

279-
inputs.input("Enter name for new directory:", base, function(destination)
280-
if not destination or destination == base then
281-
return
282-
end
279+
inputs.input("Enter name for new directory:", base, function(destinations)
280+
for _, destination in ipairs(utils.brace_expand(destinations)) do
281+
if not destination or destination == base then
282+
return
283+
end
283284

284-
if using_root_directory then
285-
destination = utils.path_join(using_root_directory, destination)
286-
else
287-
destination = vim.fn.fnamemodify(destination, ":p")
288-
end
285+
if using_root_directory then
286+
destination = utils.path_join(using_root_directory, destination)
287+
else
288+
destination = vim.fn.fnamemodify(destination, ":p")
289+
end
289290

290-
if loop.fs_stat(destination) then
291-
log.warn("Directory already exists")
292-
return
293-
end
291+
if loop.fs_stat(destination) then
292+
log.warn("Directory already exists")
293+
return
294+
end
294295

295-
create_all_parents(destination)
296-
loop.fs_mkdir(destination, 493)
296+
create_all_parents(destination)
297+
loop.fs_mkdir(destination, 493)
297298

298-
vim.schedule(function()
299-
events.fire_event(events.FILE_ADDED, destination)
300-
if callback then
301-
callback(destination)
302-
end
303-
end)
299+
vim.schedule(function()
300+
events.fire_event(events.FILE_ADDED, destination)
301+
if callback then
302+
callback(destination)
303+
end
304+
end)
305+
end
304306
end)
305307
end
306308

@@ -323,43 +325,45 @@ M.create_node = function(in_directory, callback, using_root_directory)
323325
inputs.input(
324326
'Enter name for new file or directory (dirs end with a "/"):',
325327
base,
326-
function(destination)
327-
if not destination or destination == base then
328-
return
329-
end
330-
local is_dir = vim.endswith(destination, "/")
331-
332-
if using_root_directory then
333-
destination = utils.path_join(using_root_directory, destination)
334-
else
335-
destination = vim.fn.fnamemodify(destination, ":p")
336-
end
328+
function(destinations)
329+
for _, destination in ipairs(utils.brace_expand(destinations)) do
330+
if not destination or destination == base then
331+
return
332+
end
333+
local is_dir = vim.endswith(destination, "/")
337334

338-
if loop.fs_stat(destination) then
339-
log.warn("File already exists")
340-
return
341-
end
335+
if using_root_directory then
336+
destination = utils.path_join(using_root_directory, destination)
337+
else
338+
destination = vim.fn.fnamemodify(destination, ":p")
339+
end
342340

343-
create_all_parents(destination)
344-
if is_dir then
345-
loop.fs_mkdir(destination, 493)
346-
else
347-
local open_mode = loop.constants.O_CREAT + loop.constants.O_WRONLY + loop.constants.O_TRUNC
348-
local fd = loop.fs_open(destination, "w", open_mode)
349-
if not fd then
350-
api.nvim_err_writeln("Could not create file " .. destination)
341+
if loop.fs_stat(destination) then
342+
log.warn("File already exists")
351343
return
352344
end
353-
loop.fs_chmod(destination, 420)
354-
loop.fs_close(fd)
355-
end
356345

357-
vim.schedule(function()
358-
events.fire_event(events.FILE_ADDED, destination)
359-
if callback then
360-
callback(destination)
346+
create_all_parents(destination)
347+
if is_dir then
348+
loop.fs_mkdir(destination, 493)
349+
else
350+
local open_mode = loop.constants.O_CREAT + loop.constants.O_WRONLY + loop.constants.O_TRUNC
351+
local fd = loop.fs_open(destination, "w", open_mode)
352+
if not fd then
353+
api.nvim_err_writeln("Could not create file " .. destination)
354+
return
355+
end
356+
loop.fs_chmod(destination, 420)
357+
loop.fs_close(fd)
361358
end
362-
end)
359+
360+
vim.schedule(function()
361+
events.fire_event(events.FILE_ADDED, destination)
362+
if callback then
363+
callback(destination)
364+
end
365+
end)
366+
end
363367
end
364368
)
365369
end

lua/neo-tree/utils.lua

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -823,4 +823,161 @@ M.unique = function(list)
823823
return result
824824
end
825825

826+
---Splits string by sep on first occurrence. brace_expand_split("a,b,c", ",") -> { "a", "b,c" }. nil if separator not found.
827+
---@param s string: input string
828+
---@param separator string: separator
829+
---@return string, string | nil
830+
local brace_expand_split = function(s, separator)
831+
local pos = 1
832+
local depth = 0
833+
while pos <= s:len() do
834+
local c = s:sub(pos, pos)
835+
if c == '\\' then
836+
pos = pos + 1
837+
elseif c == separator and depth == 0 then
838+
return s:sub(1, pos - 1), s:sub(pos + 1)
839+
elseif c == '{' then
840+
depth = depth + 1
841+
elseif c == '}' then
842+
if depth > 0 then
843+
depth = depth - 1
844+
end
845+
end
846+
pos = pos + 1
847+
end
848+
return s, nil
849+
end
850+
851+
---Perform brace expansion on a string and return the sequence of the results
852+
---@param s string?: input string which is inside braces, if nil return { "" }
853+
---@return string[] | nil: list of strings each representing the individual expanded strings
854+
local brace_expand_contents = function(s)
855+
if s == nil then -- no closing brace "}"
856+
return { "" }
857+
elseif s == "" then -- brace with no content "{}"
858+
return { "{}" }
859+
end
860+
861+
---Generate a sequence from from..to..step and apply `func`
862+
---@param from string | number: initial value
863+
---@param to string | number: end value
864+
---@param step string | number: step value
865+
---@param func fun(i: number): string | nil function(string | number) -> string | nil: function applied to all values in sequence. if return is nil, the value will be ignored.
866+
---@return string[]: generated string list
867+
---@private
868+
local function resolve_sequence(from, to, step, func)
869+
local f, t = tonumber(from), tonumber(to)
870+
local st = (t < f and -1 or 1) * math.abs(tonumber(step) or 1) -- reverse (negative) step if t < f
871+
---@type string[]
872+
local items = {}
873+
for i = f, t, st do
874+
local r = func(i)
875+
if r ~= nil then
876+
table.insert(items, r)
877+
end
878+
end
879+
return items
880+
end
881+
882+
---If pattern matches the input string `s`, apply an expansion by `resolve_func`
883+
---@param pattern string: regex to match on `s`
884+
---@param resolve_func fun(from: string, to: string, step: string): string[]
885+
---@return string[] | nil: expanded sequence or nil if failed
886+
local function try_sequence_on_pattern(pattern, resolve_func)
887+
local from, to, step = string.match(s, pattern)
888+
if from then
889+
return resolve_func(from, to, step)
890+
end
891+
return nil
892+
end
893+
894+
---Process numeric sequence expression. e.g. {0..2} -> {0,1,2}, {01..05..2} -> {01,03,05}
895+
local resolve_sequence_num = function(from, to, step)
896+
local format = '%d'
897+
-- Pad strings in the presence of a leading zero
898+
local pattern = '^-?0%d'
899+
if from:match(pattern) or to:match(pattern) then
900+
format = '%0' .. math.max(#from, #to) .. 'd'
901+
end
902+
return resolve_sequence(from, to, step, function(i)
903+
return string.format(format, i)
904+
end)
905+
end
906+
907+
---Process alphabet sequence expression. e.g. {a..c} -> {a,b,c}, {a..e..2} -> {a,c,e}
908+
local resolve_sequence_char = function(from, to, step)
909+
return resolve_sequence(from:byte(), to:byte(), step, function(i)
910+
return i ~= 92 and string.char(i) or nil -- 92 == '\\' is ignored in bash
911+
end)
912+
end
913+
914+
local check_list = {
915+
{ [=[^(-?%d+)%.%.(-?%d+)%.%.(-?%d+)$]=], resolve_sequence_num },
916+
{ [=[^(-?%d+)%.%.(-?%d+)$]=], resolve_sequence_num },
917+
{ [=[^(%a)%.%.(%a)%.%.(-?%d+)$]=], resolve_sequence_char },
918+
{ [=[^(%a)%.%.(%a)$]=], resolve_sequence_char },
919+
}
920+
for _, list in ipairs(check_list) do
921+
local regex, func = table.unpack(list)
922+
local sequence = try_sequence_on_pattern(regex, func)
923+
if sequence then
924+
return sequence
925+
end
926+
end
927+
928+
-- Regular `,` separated expression. x{a,b,c} -> {xa,xb,xc}
929+
local items, tmp_s = {}, nil
930+
tmp_s = s
931+
while tmp_s ~= nil do
932+
items[#items + 1], tmp_s = brace_expand_split(tmp_s, ",")
933+
end
934+
if #items == 1 then -- Only one expansion found. Abort.
935+
return nil
936+
end
937+
return vim.tbl_flatten(items)
938+
end
939+
940+
---brace_expand:
941+
-- Perform a BASH style brace expansion to generate arbitrary strings.
942+
-- Especially useful for specifying structured file / dir names.
943+
-- USAGE:
944+
-- - `require("neo-tree.utils").brace_expand("x{a..e..2}")` -> `{ "xa", "xc", "xe" }`
945+
-- - `require("neo-tree.utils").brace_expand("file.txt{,.bak}")` -> `{ "file.txt", "file.txt.bak" }`
946+
-- - `require("neo-tree.utils").brace_expand("./{a,b}/{00..02}.lua")` -> `{ "./a/00.lua", "./a/01.lua", "./a/02.lua", "./b/00.lua", "./b/01.lua", "./b/02.lua" }`
947+
-- More examples for BASH style brace expansion can be found here: https://facelessuser.github.io/bracex/
948+
---@param s string: input string. e.g. {a..e..2} -> {a,c,e}, {00..05..2} -> {00,03,05}
949+
---@return string[]: result of expansion, array with at least one string (one means it failed to expand and the raw string is returned)
950+
M.brace_expand = function(s)
951+
local preamble, postamble = brace_expand_split(s, '{')
952+
if postamble == nil then
953+
return { s }
954+
end
955+
956+
local expr, postscript, contents = nil, nil, nil
957+
postscript = postamble
958+
while contents == nil do
959+
local old_expr = expr
960+
expr, postscript = brace_expand_split(postscript, '}')
961+
if old_expr then
962+
expr = old_expr .. '}' .. expr
963+
end
964+
if postscript == nil then -- No closing brace found, so we put back the unmatched '{'
965+
preamble = preamble .. '{'
966+
expr, postscript = nil, postamble
967+
end
968+
contents = brace_expand_contents(expr)
969+
end
970+
971+
-- Concat everything. Pass postscript recursively.
972+
---@type string[]
973+
local result = {}
974+
for _, item in ipairs(contents) do
975+
for _, suffix in ipairs(M.brace_expand(postscript)) do
976+
result[#result + 1] = table.concat({ preamble, item, suffix })
977+
end
978+
end
979+
return result
980+
end
981+
982+
826983
return M

0 commit comments

Comments
 (0)