local vim = vim local log = require("neo-tree.log") local bit = require("bit") local ffi = require("ffi") local FILE_ATTRIBUTE_HIDDEN = 0x2 ffi.cdef([[ int GetFileAttributesA(const char *path); ]]) -- Backwards compatibility table.pack = table.pack or function(...) return { n = select("#", ...), ... } end table.unpack = table.unpack or unpack local M = {} local diag_severity_to_string = function(severity) if severity == vim.diagnostic.severity.ERROR then return "Error" elseif severity == vim.diagnostic.severity.WARN then return "Warn" elseif severity == vim.diagnostic.severity.INFO then return "Info" elseif severity == vim.diagnostic.severity.HINT then return "Hint" else return nil end end local tracked_functions = {} M.debounce_strategy = { CALL_FIRST_AND_LAST = 0, CALL_LAST_ONLY = 1, } M.debounce_action = { START_NORMAL = 0, START_ASYNC_JOB = 1, COMPLETE_ASYNC_JOB = 2, } local defer_function -- Part of debounce. Moved out of the function to eliminate memory leaks. defer_function = function(id, frequency_in_ms, strategy, action) tracked_functions[id].in_debounce_period = true vim.defer_fn(function() local current_data = tracked_functions[id] if not current_data then return end if current_data.async_in_progress then defer_function(id, frequency_in_ms, strategy, action) return end local _fn = current_data.fn current_data.fn = nil current_data.in_debounce_period = false if _fn ~= nil then M.debounce(id, _fn, frequency_in_ms, strategy, action) end end, frequency_in_ms) end ---Call fn, but not more than once every x milliseconds. ---@param id string Identifier for the debounce group, such as the function name. ---@param fn function Function to be executed. ---@param frequency_in_ms number Miniumum amount of time between invocations of fn. ---@param strategy number The debounce_strategy to use, determines which calls to fn are not dropped. M.debounce = function(id, fn, frequency_in_ms, strategy, action) local fn_data = tracked_functions[id] if fn_data == nil then if action == M.debounce_action.COMPLETE_ASYNC_JOB then -- original call complete and no further requests have been made return end -- first call for this id fn_data = { id = id, in_debounce_period = false, fn = fn, frequency_in_ms = frequency_in_ms, } tracked_functions[id] = fn_data if strategy == M.debounce_strategy.CALL_LAST_ONLY then defer_function(id, frequency_in_ms, strategy, action) return end else fn_data.fn = fn fn_data.frequency_in_ms = frequency_in_ms if action == M.debounce_action.COMPLETE_ASYNC_JOB then fn_data.async_in_progress = false return elseif fn_data.async_in_progress then defer_function(id, frequency_in_ms, strategy, action) return end end if fn_data.in_debounce_period then -- This id was called recently and can't be executed again yet. -- Last one in wins. return end -- Run the requested function normally. -- Use a pcall to ensure the debounce period is still respected even if -- this call throws an error. local success, result = true, nil fn_data.in_debounce_period = true if type(fn) == "function" then success, result = pcall(fn) end fn_data.fn = nil fn = nil if not success then log.error("debounce ", id, " error: ", result) elseif result and action == M.debounce_action.START_ASYNC_JOB then -- This can't fire again until the COMPLETE_ASYNC_JOB signal is sent. fn_data.async_in_progress = true end if strategy == M.debounce_strategy.CALL_LAST_ONLY then if fn_data.async_in_progress then defer_function(id, frequency_in_ms, strategy, action) else -- We are done with this debounce tracked_functions[id] = nil end else -- Now schedule the next earliest execution. -- If there are no calls to run the same function between now -- and when this deferred executes, nothing will happen. -- If there are several calls, only the last one in will run. strategy = M.debounce_strategy.CALL_LAST_ONLY defer_function(id, frequency_in_ms, strategy, action) end end --- Returns true if the contents of two tables are equal. M.tbl_equals = function(table1, table2) -- same object if table1 == table2 then return true end -- not the same type if type(table1) ~= "table" or type(table2) ~= "table" then return false end -- If tables are lists, check if they have the same values in the same order if #table1 ~= #table2 then return false end for i, v in ipairs(table1) do if table2[i] ~= v then return false end end -- Check if the tables have the same key/value pairs for k, v in pairs(table1) do if table2[k] ~= v then return false end end for k, v in pairs(table2) do if table1[k] ~= v then return false end end -- No differences found, tables are equal return true end M.execute_command = function(cmd) local result = vim.fn.systemlist(cmd) -- An empty result is ok if vim.v.shell_error ~= 0 or (#result > 0 and vim.startswith(result[1], "fatal:")) then return false, {} else return true, result end end M.find_buffer_by_name = function(name) for _, buf in ipairs(vim.api.nvim_list_bufs()) do local buf_name = vim.api.nvim_buf_get_name(buf) if buf_name == name then return buf end end return -1 end ---Gets diagnostic severity counts for all files ---@return table table { file_path = { Error = int, Warning = int, Information = int, Hint = int, Unknown = int } } M.get_diagnostic_counts = function() local d = vim.diagnostic.get() local lookup = {} for _, diag in ipairs(d) do if diag.source == "Lua Diagnostics." and diag.message == "Undefined global `vim`." then -- ignore this diagnostic else local success, file_name = pcall(vim.api.nvim_buf_get_name, diag.bufnr) if success then local sev = diag_severity_to_string(diag.severity) if sev then local entry = lookup[file_name] or { severity_number = 4 } entry[sev] = (entry[sev] or 0) + 1 entry.severity_number = math.min(entry.severity_number, diag.severity) entry.severity_string = diag_severity_to_string(entry.severity_number) lookup[file_name] = entry end end end end for file_name, entry in pairs(lookup) do -- Now bubble this status up to the parent directories local parts = M.split(file_name, M.path_separator) table.remove(parts) -- pop the last part so we don't override the file's status M.reduce(parts, "", function(acc, part) local path = (M.is_windows and acc == "") and part or M.path_join(acc, part) local path_entry = lookup[path] or { severity_number = 4 } path_entry.severity_number = math.min(path_entry.severity_number, entry.severity_number) path_entry.severity_string = diag_severity_to_string(path_entry.severity_number) lookup[path] = path_entry return path end) end return lookup end --- DEPRECATED: This will be removed in v3. Use `get_opened_buffers` instead. ---Gets a lookup of all open buffers keyed by path with the modifed flag as the value ---@return table opened_buffers { [buffer_name] = bool } M.get_modified_buffers = function() local opened_buffers = M.get_opened_buffers() for bufname, bufinfo in pairs(opened_buffers) do opened_buffers[bufname] = bufinfo.modified end return opened_buffers end ---Gets a lookup of all open buffers keyed by path with additional information ---@return table opened_buffers { [buffer_name] = { modified = bool } } M.get_opened_buffers = function() local opened_buffers = {} for _, buffer in ipairs(vim.api.nvim_list_bufs()) do if vim.fn.buflisted(buffer) ~= 0 then local buffer_name = vim.api.nvim_buf_get_name(buffer) if buffer_name == nil or buffer_name == "" then buffer_name = "[No Name]#" .. buffer end opened_buffers[buffer_name] = { ["modified"] = vim.api.nvim_buf_get_option(buffer, "modified"), ["loaded"] = vim.api.nvim_buf_is_loaded(buffer), } end end return opened_buffers end ---Resolves some variable to a string. The object can be either a string or a --function that returns a string. ---@param functionOrString any The object to resolve. ---@param node table The current node, which is passed to the function if it is a function. ---@param state any The current state, which is passed to the function if it is a function. ---@return string string The resolved string. M.getStringValue = function(functionOrString, node, state) if type(functionOrString) == "function" then return functionOrString(node, state) else return functionOrString end end ---Return the keys of a given table. ---@param tbl table The table to get the keys of. ---@param sorted boolean Whether to sort the keys. ---@return table table The keys of the table. M.get_keys = function(tbl, sorted) local keys = {} for k, _ in pairs(tbl) do table.insert(keys, k) end if sorted then table.sort(keys) end return keys end ---Gets the usable columns in a window, subtracting sign, fold, and line number columns. ---@param winid integer The window id to get the columns of. ---@return number M.get_inner_win_width = function(winid) local info = vim.fn.getwininfo(winid) if info and info[1] then return info[1].width - info[1].textoff else log.error("Could not get window info for window", winid) end end ---Handles null coalescing into a table at any depth. ---@param sourceObject table The table to get a vlue from. ---@param valuePath string The path to the value to get. ---@param defaultValue any The default value to return if the value is nil. ---@param strict_type_check boolean Whether to require the type of the value is ---the same as the default value. ---@return table|nil table The value at the path or the default value. M.get_value = function(sourceObject, valuePath, defaultValue, strict_type_check) if sourceObject == nil then return defaultValue end local pathParts = M.split(valuePath, ".") local currentTable = sourceObject for _, part in ipairs(pathParts) do if currentTable[part] == nil then return defaultValue else currentTable = currentTable[part] end end if currentTable ~= nil then return currentTable end if strict_type_check then if type(defaultValue) == type(currentTable) then return currentTable else return defaultValue end end end ---Sets a value at a path in a table, creating any missing tables along the way. ---@param sourceObject table The table to set a value in. ---@param valuePath string The path to the value to set. ---@param value any The value to set. M.set_value = function(sourceObject, valuePath, value) local pathParts = M.split(valuePath, ".") local currentTable = sourceObject for i, part in ipairs(pathParts) do if i == #pathParts then currentTable[part] = value else currentTable = currentTable[part] end end end ---Groups an array of items by a key. ---@param array table The array to group. ---@param key string The key to group by. ---@return table table The grouped array where the keys are the unique values of the specified key. M.group_by = function(array, key) local result = {} for _, item in ipairs(array) do local keyValue = item[key] local group = result[keyValue] if group == nil then group = {} result[keyValue] = group end table.insert(group, item) end return result end ---Determines if a file should be filtered by a given list of glob patterns. ---@param pattern_list table The list of glob patterns to filter by. ---@param path string The full path to the file. ---@param name string|nil The name of the file. ---@return boolean M.is_filtered_by_pattern = function(pattern_list, path, name) if pattern_list == nil then return false end if name == nil then _, name = M.split_path(path) end for _, p in ipairs(pattern_list) do local separator_pattern = M.is_windows and "\\" or "/" local filename = string.find(p, separator_pattern) and path or name if string.find(filename, p) then return true end end return false end M.is_floating = function(win_id) win_id = win_id or vim.api.nvim_get_current_win() local cfg = vim.api.nvim_win_get_config(win_id) if cfg.relative > "" or cfg.external then return true end return false end ---Evaluates the value of , which comes from an autocmd event, and determines if it ---is a valid file or some sort of utility buffer like quickfix or neo-tree itself. ---@param afile string The path or relative path to the file. ---@param true_for_terminals boolean? Whether to return true for terminals, normally it would be false. ---@return boolean boolean Whether the buffer is a real file. M.is_real_file = function(afile, true_for_terminals) if type(afile) ~= "string" or afile == "" or afile == "quickfix" then return false end local source = afile:match("^neo%-tree ([%l%-]+) %[%d+%]") if source then return false end local success, bufnr = pcall(vim.fn.bufnr, afile) if success and bufnr > 0 then local buftype = vim.api.nvim_buf_get_option(bufnr, "buftype") if true_for_terminals and buftype == "terminal" then return true end -- all other buftypes are not real files if M.truthy(buftype) then return false end return true else return false end end ---Creates a new table from an array with the array items as keys. If a dict like ---table is passed in, those keys will be copied to a new table. ---@param tbl table The table to copy items from. ---@return table table A new dictionary style table. M.list_to_dict = function(tbl) local dict = {} -- leave the existing keys for key, val in pairs(tbl) do dict[key] = val end -- and convert the number indexed items for _, item in ipairs(tbl) do dict[item] = true end return dict end M.map = function(tbl, fn) local t = {} for k, v in pairs(tbl) do t[k] = fn(v) end return t end M.get_appropriate_window = function(state) -- Avoid triggering autocommands when switching windows local eventignore = vim.o.eventignore vim.o.eventignore = "all" local current_window = vim.api.nvim_get_current_win() -- use last window if possible local suitable_window_found = false local nt = require("neo-tree") local ignore_ft = nt.config.open_files_do_not_replace_types local ignore = M.list_to_dict(ignore_ft) ignore["neo-tree"] = true if nt.config.open_files_in_last_window then local prior_window = nt.get_prior_window(ignore) if prior_window > 0 then local success = pcall(vim.api.nvim_set_current_win, prior_window) if success then suitable_window_found = true end end end -- find a suitable window to open the file in if not suitable_window_found then if state.current_position == "right" then vim.cmd("wincmd t") else vim.cmd("wincmd w") end end local attempts = 0 while attempts < 5 and not suitable_window_found do local bt = vim.bo.buftype or "normal" if ignore[vim.bo.filetype] or ignore[bt] or M.is_floating() then attempts = attempts + 1 vim.cmd("wincmd w") else suitable_window_found = true end end if not suitable_window_found then -- go back to the neotree window, this will forve it to open a new split vim.api.nvim_set_current_win(current_window) end local winid = vim.api.nvim_get_current_win() local is_neo_tree_window = vim.bo.filetype == "neo-tree" vim.api.nvim_set_current_win(current_window) vim.o.eventignore = eventignore return winid, is_neo_tree_window end ---Resolves the width to a number ---@param width number|string|function M.resolve_width = function(width) local default_width = 40 local available_width = vim.o.columns if type(width) == "string" then if string.sub(width, -1) == "%" then width = tonumber(string.sub(width, 1, #width - 1)) / 100 width = width * available_width else width = tonumber(width) end elseif type(width) == "function" then width = width() end if type(width) ~= "number" then width = default_width end return math.floor(width) end ---Open file in the appropriate window. ---@param state table The state of the source ---@param path string The file to open ---@param open_cmd string The vimcommand to use to open the file ---@param bufnr number|nil The buffer number to open M.open_file = function(state, path, open_cmd, bufnr) open_cmd = open_cmd or "edit" if open_cmd == "edit" or open_cmd == "e" then -- If the file is already open, switch to it. bufnr = bufnr or M.find_buffer_by_name(path) if bufnr <= 0 then bufnr = nil else open_cmd = "b" end end if M.truthy(path) then local escaped_path = vim.fn.fnameescape(path) local bufnr_or_path = bufnr or escaped_path local events = require("neo-tree.events") local result = true local err = nil local event_result = events.fire_event(events.FILE_OPEN_REQUESTED, { state = state, path = path, open_cmd = open_cmd, bufnr = bufnr, }) or {} if event_result.handled then events.fire_event(events.FILE_OPENED, path) return end if state.current_position == "current" then result, err = pcall(vim.cmd, open_cmd .. " " .. bufnr_or_path) else local winid, is_neo_tree_window = M.get_appropriate_window(state) vim.api.nvim_set_current_win(winid) -- TODO: make this configurable, see issue #43 if is_neo_tree_window then local width = vim.api.nvim_win_get_width(0) if width == vim.o.columns then -- Neo-tree must be the only window, restore it's status as a sidebar width = M.get_value(state, "window.width", 40, false) width = M.resolve_width(width) end local split_command = "vsplit" -- respect window position in user config when Neo-tree is the only window if state.current_position == "left" then split_command = "rightbelow vs" elseif state.current_position == "right" then split_command = "leftabove vs" end if path == "[No Name]" then result, err = pcall(vim.cmd, split_command) if result then vim.cmd("b" .. bufnr) end else result, err = pcall(vim.cmd, split_command .. " " .. escaped_path) end vim.api.nvim_win_set_width(winid, width) else result, err = pcall(vim.cmd, open_cmd .. " " .. bufnr_or_path) end end if result or err == "Vim(edit):E325: ATTENTION" then -- fixes #321 vim.api.nvim_buf_set_option(0, "buflisted", true) events.fire_event(events.FILE_OPENED, path) else log.error("Error opening file:", err) end end end M.reduce = function(list, memo, func) for _, i in ipairs(list) do memo = func(memo, i) end return memo end M.reverse_list = function(list) local result = {} for i = #list, 1, -1 do table.insert(result, list[i]) end return result end M.resolve_config_option = function(state, config_option, default_value) local opt = M.get_value(state, config_option, default_value, false) if type(opt) == "function" then local success, val = pcall(opt, state) if success then return val else log.error("Error resolving config option: " .. config_option .. ": " .. val) return default_value end else return opt end end ---Normalize a path, to avoid errors when comparing paths. ---@param path string The path to be normalize. ---@return string string The normalized path. M.normalize_path = function(path) if M.is_windows then -- normalize the drive letter to uppercase path = path:sub(1, 1):upper() .. path:sub(2) end return path end ---Check if a path is a subpath of another. --@param base string The base path. --@param path string The path to check is a subpath. --@return boolean boolean True if it is a subpath, false otherwise. M.is_subpath = function(base, path) if not M.truthy(base) or not M.truthy(path) then return false elseif base == path then return true end base = M.normalize_path(base) path = M.normalize_path(path) return string.sub(path, 1, string.len(base)) == base end ---The file system path separator for the current platform. M.path_separator = "/" M.is_windows = vim.fn.has("win32") == 1 or vim.fn.has("win32unix") == 1 if M.is_windows == true then M.path_separator = "\\" end ---Remove the path separator from the end of a path in a cross-platform way. ---@param path string The path to remove the separator from. ---@return string string The path without any trailing separator. ---@return number count The number of separators removed. M.remove_trailing_slash = function(path) if M.is_windows then return path:gsub("\\$", "") else return path:gsub("/$", "") end end ---Sorts a list of paths in the order they would appear in a tree. ---@param paths table The list of paths to sort. ---@return table table The sorted list of paths. M.sort_by_tree_display = function(paths) -- first turn the paths into a true tree local nodes = {} local index = {} local function create_nodes(path) local node = index[path] if node then return node end local parent, name = M.split_path(path) node = { name = name, path = path, children = {}, } index[path] = node if parent == nil then table.insert(nodes, node) else local parent_node = index[parent] if parent_node == nil then parent_node = create_nodes(parent) end table.insert(parent_node.children, node) end return node end for _, path in ipairs(paths) do create_nodes(path) end -- create a lookup of the original paths so that we don't return anything -- that isn't in the original list local original_paths = M.list_to_dict(paths) -- sort folders before files local sort_by_name = function(a, b) local a_isdir = #a.children > 0 local b_isdir = #b.children > 0 if a_isdir and not b_isdir then return true elseif not a_isdir and b_isdir then return false else return a.name < b.name end end -- now we can walk the tree in the order that it would be displayed on the screen local result = {} local function walk_tree(node) if original_paths[node.path] then table.insert(result, node.path) original_paths[node.path] = nil -- just to be sure we don't return it twice end table.sort(node.children, sort_by_name) for _, child in ipairs(node.children) do walk_tree(child) end end walk_tree({ children = nodes }) return result end ---Split string into a table of strings using a separator. ---@param inputString string The string to split. ---@param sep string The separator to use. ---@return table table A table of strings. M.split = function(inputString, sep) local fields = {} local pattern = string.format("([^%s]+)", sep) local _ = string.gsub(inputString, pattern, function(c) fields[#fields + 1] = c end) return fields end ---Split a path into a parentPath and a name. ---@param path string The path to split. ---@return string|nil parentPath ---@return string|nil name M.split_path = function(path) if not path then return nil, nil end if path == M.path_separator then return nil, M.path_separator end local parts = M.split(path, M.path_separator) local name = table.remove(parts) local parentPath = table.concat(parts, M.path_separator) if M.is_windows then if #parts == 1 then parentPath = parentPath .. M.path_separator elseif parentPath == "" then return nil, name end else parentPath = M.path_separator .. parentPath end return parentPath, name end ---Joins arbitrary number of paths together. ---@param ... string The paths to join. ---@return string M.path_join = function(...) local args = { ... } if #args == 0 then return "" end local all_parts = {} if type(args[1]) == "string" and args[1]:sub(1, 1) == M.path_separator then all_parts[1] = "" end for _, arg in ipairs(args) do if arg == "" and #all_parts == 0 and not M.is_windows then all_parts = { "" } else local arg_parts = M.split(arg, M.path_separator) vim.list_extend(all_parts, arg_parts) end end return table.concat(all_parts, M.path_separator) end local table_merge_internal ---Merges overrideTable into baseTable. This mutates baseTable. ---@param base_table table The base table that provides default values. ---@param override_table table The table to override the base table with. ---@return table table The merged table. table_merge_internal = function(base_table, override_table) for k, v in pairs(override_table) do if type(v) == "table" then if type(base_table[k]) == "table" then table_merge_internal(base_table[k], v) else base_table[k] = v end else base_table[k] = v end end return base_table end ---DEPRECATED: Use vim.deepcopy(source_table, { noref = 1 }) instead. M.table_copy = function(source_table) return vim.deepcopy(source_table, { noref = 1 }) end ---DEPRECATED: Use vim.tbl_deep_extend("force", base_table, source_table) instead. M.table_merge = function(base_table, override_table) local merged_table = table_merge_internal({}, base_table) return table_merge_internal(merged_table, override_table) end ---Evaluate the truthiness of a value, according to js/python rules. ---@param value any ---@return boolean M.truthy = function(value) if value == nil then return false end if type(value) == "boolean" then return value end if type(value) == "string" then return value > "" end if type(value) == "number" then return value > 0 end if type(value) == "table" then return #vim.tbl_values(value) > 0 end return true end M.is_expandable = function(node) return node.type == "directory" or node:has_children() end M.windowize_path = function(path) return path:gsub("/", "\\") end M.wrap = function(func, ...) if type(func) ~= "function" then error("Expected function, got " .. type(func)) end local wrapped_args = { ... } return function(...) local all_args = table.pack(table.unpack(wrapped_args), ...) func(table.unpack(all_args)) end end ---Checks if the given path is hidden using the Windows hidden file/directory logic ---@param path string ---@return boolean function M.is_hidden(path) if not M.is_windows then return false end return bit.band(ffi.C.GetFileAttributesA(path), FILE_ATTRIBUTE_HIDDEN) ~= 0 end ---Returns a new list that is the result of dedeuplicating a list. ---@param list table The list to deduplicate. ---@return table table The list of unique values. M.unique = function(list) local seen = {} local result = {} for _, item in ipairs(list) do if not seen[item] then table.insert(result, item) seen[item] = true end end return result end ---Splits string by sep on first occurrence. brace_expand_split("a,b,c", ",") -> { "a", "b,c" }. nil if separator not found. ---@param s string: input string ---@param separator string: separator ---@return string, string | nil local brace_expand_split = function(s, separator) local pos = 1 local depth = 0 while pos <= s:len() do local c = s:sub(pos, pos) if c == "\\" then pos = pos + 1 elseif c == separator and depth == 0 then return s:sub(1, pos - 1), s:sub(pos + 1) elseif c == "{" then depth = depth + 1 elseif c == "}" then if depth > 0 then depth = depth - 1 end end pos = pos + 1 end return s, nil end ---Perform brace expansion on a string and return the sequence of the results ---@param s string?: input string which is inside braces, if nil return { "" } ---@return string[] | nil: list of strings each representing the individual expanded strings local brace_expand_contents = function(s) if s == nil then -- no closing brace "}" return { "" } elseif s == "" then -- brace with no content "{}" return { "{}" } end ---Generate a sequence from from..to..step and apply `func` ---@param from string | number: initial value ---@param to string | number: end value ---@param step string | number: step value ---@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. ---@return string[]: generated string list ---@private local function resolve_sequence(from, to, step, func) local f, t = tonumber(from), tonumber(to) local st = (t < f and -1 or 1) * math.abs(tonumber(step) or 1) -- reverse (negative) step if t < f ---@type string[] local items = {} for i = f, t, st do local r = func(i) if r ~= nil then table.insert(items, r) end end return items end ---If pattern matches the input string `s`, apply an expansion by `resolve_func` ---@param pattern string: regex to match on `s` ---@param resolve_func fun(from: string, to: string, step: string): string[] ---@return string[] | nil: expanded sequence or nil if failed local function try_sequence_on_pattern(pattern, resolve_func) local from, to, step = string.match(s, pattern) if from then return resolve_func(from, to, step) end return nil end ---Process numeric sequence expression. e.g. {0..2} -> {0,1,2}, {01..05..2} -> {01,03,05} local resolve_sequence_num = function(from, to, step) local format = "%d" -- Pad strings in the presence of a leading zero local pattern = "^-?0%d" if from:match(pattern) or to:match(pattern) then format = "%0" .. math.max(#from, #to) .. "d" end return resolve_sequence(from, to, step, function(i) return string.format(format, i) end) end ---Process alphabet sequence expression. e.g. {a..c} -> {a,b,c}, {a..e..2} -> {a,c,e} local resolve_sequence_char = function(from, to, step) return resolve_sequence(from:byte(), to:byte(), step, function(i) return i ~= 92 and string.char(i) or nil -- 92 == '\\' is ignored in bash end) end local check_list = { { [=[^(-?%d+)%.%.(-?%d+)%.%.(-?%d+)$]=], resolve_sequence_num }, { [=[^(-?%d+)%.%.(-?%d+)$]=], resolve_sequence_num }, { [=[^(%a)%.%.(%a)%.%.(-?%d+)$]=], resolve_sequence_char }, { [=[^(%a)%.%.(%a)$]=], resolve_sequence_char }, } for _, list in ipairs(check_list) do local regex, func = table.unpack(list) local sequence = try_sequence_on_pattern(regex, func) if sequence then return sequence end end -- Regular `,` separated expression. x{a,b,c} -> {xa,xb,xc} local items, tmp_s = {}, nil tmp_s = s while tmp_s ~= nil do items[#items + 1], tmp_s = brace_expand_split(tmp_s, ",") end if #items == 1 then -- Only one expansion found. Abort. return nil end return vim.tbl_flatten(items) end ---brace_expand: -- Perform a BASH style brace expansion to generate arbitrary strings. -- Especially useful for specifying structured file / dir names. -- USAGE: -- - `require("neo-tree.utils").brace_expand("x{a..e..2}")` -> `{ "xa", "xc", "xe" }` -- - `require("neo-tree.utils").brace_expand("file.txt{,.bak}")` -> `{ "file.txt", "file.txt.bak" }` -- - `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" }` -- More examples for BASH style brace expansion can be found here: https://facelessuser.github.io/bracex/ ---@param s string: input string. e.g. {a..e..2} -> {a,c,e}, {00..05..2} -> {00,03,05} ---@return string[]: result of expansion, array with at least one string (one means it failed to expand and the raw string is returned) M.brace_expand = function(s) local preamble, postamble = brace_expand_split(s, "{") if postamble == nil then return { s } end local expr, postscript, contents = nil, nil, nil postscript = postamble while contents == nil do local old_expr = expr expr, postscript = brace_expand_split(postscript, "}") if old_expr then expr = old_expr .. "}" .. expr end if postscript == nil then -- No closing brace found, so we put back the unmatched '{' preamble = preamble .. "{" expr, postscript = nil, postamble end contents = brace_expand_contents(expr) end -- Concat everything. Pass postscript recursively. ---@type string[] local result = {} for _, item in ipairs(contents) do for _, suffix in ipairs(M.brace_expand(postscript)) do result[#result + 1] = table.concat({ preamble, item, suffix }) end end return result end return M