nirish

config files for niri window manager
Log | Files | Refs | README | LICENSE

utils.lua (22864B)


      1 --------------------------------------------------------------------------------------------------------
      2 -----------------------------------------Utility Functions----------------------------------------------
      3 ---------------------------------------Part of the addon API--------------------------------------------
      4 --------------------------------------------------------------------------------------------------------
      5 
      6 local mp = require 'mp'
      7 local msg = require 'mp.msg'
      8 local utils = require 'mp.utils'
      9 
     10 local o = require 'modules.options'
     11 local g = require 'modules.globals'
     12 
     13 local input_loaded, input = pcall(require, 'mp.input')
     14 local user_input_loaded, user_input = pcall(require, 'user-input-module')
     15 
     16 --creates a table for the API functions
     17 --adds one metatable redirect to prevent addon authors from accidentally breaking file-browser
     18 ---@class fb_utils
     19 local fb_utils = { API_VERSION = g.API_VERSION }
     20 
     21 fb_utils.list = {}
     22 fb_utils.coroutine = {}
     23 
     24 --implements table.pack if on lua 5.1
     25 if not table.pack then
     26     table.unpack = unpack  ---@diagnostic disable-line deprecated
     27 ---@diagnostic disable-next-line: duplicate-set-field
     28     function table.pack(...)
     29         local t = {n = select("#", ...), ...}
     30         return t
     31     end
     32 end
     33 
     34 ---Returns the index of the given item in the table.
     35 ---Return -1 if item does not exist.
     36 ---@generic T
     37 ---@param t T[]
     38 ---@param item T
     39 ---@param from_index? number
     40 ---@return integer
     41 function fb_utils.list.indexOf(t, item, from_index)
     42     for i = from_index or 1, #t, 1 do
     43         if t[i] == item then return i end
     44     end
     45     return -1
     46 end
     47 
     48 ---Returns whether or not the given table contains an entry that
     49 ---causes the given function to evaluate to true.
     50 ---@generic T
     51 ---@param t T[]
     52 ---@param fn fun(v: T, i: number, t: T[]): boolean
     53 ---@return boolean
     54 function fb_utils.list.some(t, fn)
     55     for i, v in ipairs(t --[=[@as any[]]=]) do
     56         if fn(v, i, t) then return true end
     57     end
     58     return false
     59 end
     60 
     61 ---Creates a new table populated with the results of
     62 ---calling a provided function on every element in t.
     63 ---@generic T
     64 ---@generic R
     65 ---@param t T[]
     66 ---@param fn fun(v: T, i: number, t: T[]): R
     67 ---@return R[]
     68 function fb_utils.list.map(t, fn)
     69     local new_t = {}
     70     for i, v in ipairs(t --[=[@as any[]]=]) do
     71         new_t[i] = fn(v, i, t) ---@diagnostic disable-line no-unknown
     72     end
     73     return new_t
     74 end
     75 
     76 ---Prints an error message and a stack trace.
     77 ---Can be passed directly to xpcall.
     78 ---@param errmsg string
     79 ---@param co? thread A coroutine to grab the stack trace from.
     80 function fb_utils.traceback(errmsg, co)
     81     if co then
     82         msg.warn(debug.traceback(co))
     83     else
     84         msg.warn(debug.traceback("", 2))
     85     end
     86     msg.error(errmsg)
     87 end
     88 
     89 ---Returns a table that stores the given table t as the __index in its metatable.
     90 ---Creates a prototypally inherited table.
     91 ---@generic T: table
     92 ---@param t T
     93 ---@return T
     94 function fb_utils.redirect_table(t)
     95     return setmetatable({}, { __index = t })
     96 end
     97 
     98 ---Sets the given table `proto` as the `__index` field in table `t`s metatable.
     99 ---@generic T: table
    100 ---@param t T
    101 ---@param proto table
    102 ---@return T
    103 function fb_utils.set_prototype(t, proto)
    104     return setmetatable(t, { __index = proto })
    105 end
    106 
    107 ---Prints an error if a coroutine returns an error.
    108 ---Unlike coroutine.resume_err this still returns the results of coroutine.resume().
    109 ---@param ... any
    110 ---@return boolean
    111 ---@return ...
    112 function fb_utils.coroutine.resume_catch(...)
    113     local returns = table.pack(coroutine.resume(...))
    114     if not returns[1] and returns[2] ~= g.ABORT_ERROR then
    115         fb_utils.traceback(returns[2], select(1, ...))
    116     end
    117     return table.unpack(returns, 1, returns.n)
    118 end
    119 
    120 ---Resumes a coroutine and prints an error if it was not sucessful.
    121 ---@param ... any
    122 ---@return boolean
    123 function fb_utils.coroutine.resume_err(...)
    124     local success, err = coroutine.resume(...)
    125     if not success and err ~= g.ABORT_ERROR then
    126         fb_utils.traceback(err, select(1, ...))
    127     end
    128     return success
    129 end
    130 
    131 ---Throws an error if not run from within a coroutine.
    132 ---In lua 5.1 there is only one return value which will be nil if run from the main thread.
    133 ---In lua 5.2 main will be true if running from the main thread.
    134 ---@param err any
    135 ---@return thread
    136 function fb_utils.coroutine.assert(err)
    137     local co, main = coroutine.running()
    138     assert(not main and co, err or "error - function must be executed from within a coroutine")
    139     return co
    140 end
    141 
    142 ---Creates a callback function to resume the current coroutine with the given time limit.
    143 ---If the time limit expires the coroutine will be resumed. The first return value will be true
    144 ---if the callback was resumed within the time limit and false otherwise.
    145 ---If time_limit is falsy then there will be no time limit and there will be no additional return value.
    146 ---@param time_limit? number seconds
    147 ---@return fun(...)
    148 function fb_utils.coroutine.callback(time_limit)
    149     local co = fb_utils.coroutine.assert("cannot create a coroutine callback for the main thread")
    150     local timer = time_limit and mp.add_timeout(time_limit, function ()
    151             msg.debug("time limit on callback expired")
    152             fb_utils.coroutine.resume_err(co, false)
    153         end)
    154 
    155     local function fn(...)
    156         if timer then
    157             if not timer:is_enabled() then return
    158             else timer:kill() end
    159             return fb_utils.coroutine.resume_err(co, true, ...)
    160         end
    161         return fb_utils.coroutine.resume_err(co, ...)
    162     end
    163     return fn
    164 end
    165 
    166 ---Puts the current coroutine to sleep for the given number of seconds.
    167 ---@async
    168 ---@param n number
    169 ---@return nil
    170 function fb_utils.coroutine.sleep(n)
    171     mp.add_timeout(n, fb_utils.coroutine.callback())
    172     coroutine.yield()
    173 end
    174 
    175 ---Runs the given function in a coroutine, passing through any additional arguments.
    176 ---Does not run the coroutine immediately, instead it queues the coroutine to run when the thread is next idle.
    177 ---Returns the coroutine object so that the caller can act on it before it is run.
    178 ---@param fn async fun()
    179 ---@param ... any
    180 ---@return thread
    181 function fb_utils.coroutine.queue(fn, ...)
    182     local co = coroutine.create(fn)
    183     local args = table.pack(...)
    184     mp.add_timeout(0, function() fb_utils.coroutine.resume_err(co, table.unpack(args, 1, args.n)) end)
    185     return co
    186 end
    187 
    188 ---Runs the given function in a coroutine, passing through any additional arguments.
    189 ---This is for triggering an event in a coroutine.
    190 ---@param fn async fun()
    191 ---@param ... any
    192 function fb_utils.coroutine.run(fn, ...)
    193     local co = coroutine.create(fn)
    194     fb_utils.coroutine.resume_err(co, ...)
    195 end
    196 
    197 ---Get the full path for the current file.
    198 ---@param item Item
    199 ---@param dir? string
    200 ---@return string
    201 function fb_utils.get_full_path(item, dir)
    202     if item.path then return item.path end
    203     return (dir or g.state.directory)..item.name
    204 end
    205 
    206 ---Gets the path for a new subdirectory, redirects if the path field is set.
    207 ---Returns the new directory path and a boolean specifying if a redirect happened.
    208 ---@param item Item
    209 ---@param directory string
    210 ---@return string new_directory
    211 ---@return boolean? redirected `true` if the path was redirected
    212 function fb_utils.get_new_directory(item, directory)
    213     if item.path and item.redirect ~= false then return item.path, true end
    214     if directory == "" then return item.name end
    215     if string.sub(directory, -1) == "/" then return directory..item.name end
    216     return directory.."/"..item.name
    217 end
    218 
    219 ---Returns the file extension of the given file, or def if there is none.
    220 ---@generic T
    221 ---@param filename string
    222 ---@param def? T 
    223 ---@return string|T
    224 ---@overload fun(filename: string): string|nil
    225 function fb_utils.get_extension(filename, def)
    226     return string.lower(filename):match("%.([^%./]+)$") or def
    227 end
    228 
    229 ---Returns the protocol scheme of the given url, or def if there is none.
    230 ---@generic T
    231 ---@param filename string
    232 ---@param def T
    233 ---@return string|T
    234 ---@overload fun(filename: string): string|nil
    235 function fb_utils.get_protocol(filename, def)
    236     return string.lower(filename):match("^(%a[%w+-.]*)://") or def
    237 end
    238 
    239 ---Formats strings for ass handling.
    240 ---This function is based on a similar function from
    241 ---https://github.com/mpv-player/mpv/blob/master/player/lua/console.lua#L110.
    242 ---@param str string
    243 ---@param replace_newline? true|string
    244 ---@return string
    245 function fb_utils.ass_escape(str, replace_newline)
    246     if replace_newline == true then replace_newline = "\\\239\187\191n" end
    247 
    248     --escape the invalid single characters
    249     str = string.gsub(str, '[\\{}\n]', {
    250         -- There is no escape for '\' in ASS (I think?) but '\' is used verbatim if
    251         -- it isn't followed by a recognised character, so add a zero-width
    252         -- non-breaking space
    253         ['\\'] = '\\\239\187\191',
    254         ['{'] = '\\{',
    255         ['}'] = '\\}',
    256         -- Precede newlines with a ZWNBSP to prevent ASS's weird collapsing of
    257         -- consecutive newlines
    258         ['\n'] = '\239\187\191\\N',
    259     })
    260 
    261     -- Turn leading spaces into hard spaces to prevent ASS from stripping them
    262     str = str:gsub('\\N ', '\\N\\h')
    263     str = str:gsub('^ ', '\\h')
    264 
    265     if replace_newline then
    266         str = string.gsub(str, "\\N", replace_newline)
    267     end
    268     return str
    269 end
    270 
    271 ---Escape lua pattern characters.
    272 ---@param str string
    273 ---@return string
    274 function fb_utils.pattern_escape(str)
    275     return (string.gsub(str, "([%^%$%(%)%%%.%[%]%*%+%-])", "%%%1"))
    276 end
    277 
    278 ---Standardises filepaths across systems.
    279 ---@param str string
    280 ---@param is_directory? boolean
    281 ---@return string
    282 function fb_utils.fix_path(str, is_directory)
    283     if str == '' then return str end
    284     if o.normalise_backslash == 'yes' or (o.normalise_backslash == 'auto' and g.PLATFORM == 'windows') then
    285         str = string.gsub(str, [[\]],[[/]])
    286     end
    287     str = str:gsub([[/%./]], [[/]])
    288     if is_directory and str:sub(-1) ~= '/' then str = str..'/' end
    289     return str
    290 end
    291 
    292 ---Wrapper for mp.utils.join_path to handle protocols.
    293 ---@param working string
    294 ---@param relative string
    295 ---@return string
    296 function fb_utils.join_path(working, relative)
    297     return fb_utils.get_protocol(relative) and relative or utils.join_path(working, relative)
    298 end
    299 
    300 ---Converts the given path into an absolute path and normalises it using fb_utils.fix_path.
    301 ---@param path string
    302 ---@return string
    303 function fb_utils.absolute_path(path)
    304     local absolute_path = fb_utils.join_path(mp.get_property('working-directory', ''), path)
    305     return fb_utils.fix_path(absolute_path)
    306 end
    307 
    308 ---Sorts the table lexicographically ignoring case and accounting for leading/non-leading zeroes.
    309 ---The number format functionality was proposed by github user twophyro, and was presumably taken
    310 ---from here: http://notebook.kulchenko.com/algorithms/alphanumeric-natural-sorting-for-humans-in-lua.
    311 ---@param t List
    312 ---@return List
    313 function fb_utils.sort(t)
    314     local function padnum(n, d)
    315         return #d > 0 and ("%03d%s%.12f"):format(#n, n, tonumber(d) / (10 ^ #d))
    316             or ("%03d%s"):format(#n, n)
    317     end
    318 
    319     --appends the letter d or f to the start of the comparison to sort directories and folders as well
    320     ---@type [string,Item][]
    321     local tuples = {}
    322     for i, f in ipairs(t) do
    323         tuples[i] = {f.type:sub(1, 1) .. (f.label or f.name):lower():gsub("0*(%d+)%.?(%d*)", padnum), f}
    324     end
    325     table.sort(tuples, function(a, b)
    326         -- pretty sure that `#b[2] < #a[2]` does not do anything as they are both Item tables and not strings or arrays
    327         return a[1] == b[1] and #b[2] < #a[2] or a[1] < b[1]
    328     end)
    329     for i, tuple in ipairs(tuples) do t[i] = tuple[2] end
    330     return t
    331 end
    332 
    333 ---@param dir string
    334 ---@return boolean
    335 function fb_utils.valid_dir(dir)
    336     if o.filter_dot_dirs == 'yes' or o.filter_dot_dirs == 'auto' and g.PLATFORM ~= 'windows' then
    337         return string.sub(dir, 1, 1) ~= "."
    338     end
    339     return true
    340 end
    341 
    342 ---@param file string
    343 ---@return boolean
    344 function fb_utils.valid_file(file)
    345     if o.filter_dot_files == 'yes' or o.filter_dot_files == 'auto' and g.PLATFORM ~= 'windows' then
    346         if string.sub(file, 1, 1) == "." then return false end
    347     end
    348     if o.filter_files and not g.extensions[ fb_utils.get_extension(file, "") ] then return false end
    349     return true
    350 end
    351 
    352 ---Returns whether or not the item can be parsed.
    353 ---@param item Item
    354 ---@return boolean
    355 function fb_utils.parseable_item(item)
    356     return item.type == "dir" or g.parseable_extensions[fb_utils.get_extension(item.name, "")]
    357 end
    358 
    359 ---Takes a directory string and resolves any directory mappings,
    360 ---returning the resolved directory.
    361 ---@param path string
    362 ---@return string
    363 function fb_utils.resolve_directory_mapping(path)
    364     if not path then return path end
    365 
    366     for mapping, target in pairs(g.directory_mappings) do
    367         local start, finish = string.find(path, mapping)
    368         if start then
    369             msg.debug('mapping', mapping, 'found for', path, 'changing to', target)
    370 
    371             -- if the mapping is an exact match then return the target as is
    372             if finish == #path then return target end
    373 
    374             -- else make sure the path is correctly formatted
    375             target = fb_utils.fix_path(target, true)
    376             return (string.gsub(path, mapping, target))
    377         end
    378     end
    379 
    380     return path
    381 end
    382 
    383 ---Removes items and folders from the list that fail the configured filters.
    384 ---@param t List
    385 ---@return List
    386 function fb_utils.filter(t)
    387     local max = #t
    388     local top = 1
    389     for i = 1, max do
    390         local temp = t[i]
    391         t[i] = nil
    392 
    393         if  ( temp.type == "dir" and fb_utils.valid_dir(temp.label or temp.name) ) or
    394             ( temp.type == "file" and fb_utils.valid_file(temp.label or temp.name) )
    395         then
    396             t[top] = temp
    397             top = top+1
    398         end
    399     end
    400     return t
    401 end
    402 
    403 ---Returns a string iterator that uses the root separators.
    404 ---@param str any
    405 ---@param separators? string Override the root separators.
    406 ---@return fun():(string, ...)
    407 function fb_utils.iterate_opt(str, separators)
    408     return string.gmatch(str, "([^"..fb_utils.pattern_escape(separators or o.root_separators).."]+)")
    409 end
    410 
    411 ---Sorts a table into an array of selected items in the correct order.
    412 ---If a predicate function is passed, then the item will only be added to
    413 ---the table if the function returns true.
    414 ---@param t Set<number>
    415 ---@param include_item? fun(item: Item): boolean
    416 ---@return Item[]
    417 function fb_utils.sort_keys(t, include_item)
    418     ---@class Ref
    419     ---@field item Item
    420     ---@field index number
    421 
    422     ---@type Ref[]
    423     local keys = {}
    424     for k in pairs(t) do
    425         local item = g.state.list[k]
    426         if not include_item or include_item(item) then
    427             keys[#keys+1] = {
    428                 item = item,
    429                 index = k,
    430             }
    431         end
    432     end
    433 
    434     table.sort(keys, function(a,b) return a.index < b.index end)
    435     return fb_utils.list.map(keys, function(ref) return ref.item end)
    436 end
    437 
    438 ---Uses a loop to get the length of an array. The `#` operator is undefined if there
    439 ---are gaps in the array, this ensures there are none as expected by the mpv node function.
    440 ---@param t any[]
    441 ---@return integer
    442 local function get_length(t)
    443     local i = 1
    444     while t[i] do i = i+1 end
    445     return i - 1
    446 end
    447 
    448 ---Recursively removes elements of the table which would cause
    449 ---utils.format_json to throw an error.
    450 ---@generic T
    451 ---@param t T
    452 ---@return T
    453 local function json_safe_recursive(t)
    454     if type(t) ~= "table" then return t end
    455 
    456     local array_length = get_length(t)
    457     local isarray = array_length > 0
    458 
    459     for key, value in pairs(t --[[@as table<any,any>]]) do
    460         local ktype = type(key)
    461         local vtype = type(value)
    462 
    463         if  vtype ~= "userdata" and vtype ~= "function" and vtype ~= "thread"
    464             and ((  isarray and ktype == "number" and key <= array_length)
    465                     or (not isarray and ktype == "string"))
    466         then
    467             ---@diagnostic disable-next-line no-unknown
    468             t[key] = json_safe_recursive(t[key])
    469         elseif key then
    470             ---@diagnostic disable-next-line no-unknown
    471             t[key] = nil
    472             if isarray then array_length = get_length(t) end
    473         end
    474     end
    475     return t
    476 end
    477 
    478 ---Formats a table into a json string but ensures there are no invalid datatypes inside the table first.
    479 ---@param t any
    480 ---@return string|nil
    481 ---@return string|nil err
    482 function fb_utils.format_json_safe(t)
    483     --operate on a copy of the table to prevent any data loss in the original table
    484     t = json_safe_recursive(fb_utils.copy_table(t))
    485     local success, result, err = pcall(utils.format_json, t)
    486     if success then return result, err
    487     else return nil, result end
    488 end
    489 
    490 ---Evaluates and runs the given string in both Lua 5.1 and 5.2.
    491 ---Provides the mpv modules and the fb module to the string.
    492 ---@param str string
    493 ---@param chunkname? string Used for error reporting.
    494 ---@param custom_env? table A custom environment that shadows the default environment.
    495 ---@param env_defaults? boolean Load lua defaults in environment, as well as mpv and file-browser modules. Defaults to `true`.
    496 ---@return unknown
    497 function fb_utils.evaluate_string(str, chunkname, custom_env, env_defaults)
    498     ---@type table
    499     local env
    500     if env_defaults ~= false then
    501         ---@type table
    502         env = fb_utils.redirect_table(_G)
    503         env.mp = fb_utils.redirect_table(mp)
    504         env.msg = fb_utils.redirect_table(msg)
    505         env.utils = fb_utils.redirect_table(utils)
    506         env.fb = fb_utils.redirect_table(require 'file-browser')
    507         env.input = input_loaded and fb_utils.redirect_table(input)
    508         env.user_input = user_input_loaded and fb_utils.redirect_table(user_input)
    509         env = fb_utils.set_prototype(custom_env or {}, env)
    510     else
    511         env = custom_env or {}
    512     end
    513 
    514     ---@type function, any
    515     local chunk, err
    516     if setfenv then  ---@diagnostic disable-line deprecated
    517         chunk, err = loadstring(str, chunkname)  ---@diagnostic disable-line deprecated
    518         if chunk then setfenv(chunk, env) end  ---@diagnostic disable-line deprecated
    519     else
    520         chunk, err = load(str, chunkname, 't', env) ---@diagnostic disable-line redundant-parameter
    521     end
    522     if not chunk then
    523         msg.warn('failed to load string:', str)
    524         msg.error(err)
    525         chunk = function() return nil end
    526     end
    527 
    528     return chunk()
    529 end
    530 
    531 ---Copies a table without leaving any references to the original.
    532 ---Uses a structured clone algorithm to maintain cyclic references.
    533 ---@generic T
    534 ---@param t T
    535 ---@param references table<table,table>
    536 ---@param depth number
    537 ---@return T
    538 local function copy_table_recursive(t, references, depth)
    539     if type(t) ~= "table" or depth == 0 then return t end
    540     if references[t] then return references[t] end
    541 
    542     local copy = setmetatable({}, { __original = t })
    543     references[t] = copy
    544 
    545     for key, value in pairs(t --[[@as table<any,any>]]) do
    546         key = copy_table_recursive(key, references, depth - 1)
    547         copy[key] = copy_table_recursive(value, references, depth - 1) ---@diagnostic disable-line no-unknown
    548     end
    549     return copy
    550 end
    551 
    552 ---A wrapper around copy_table to provide the reference table.
    553 ---@generic T
    554 ---@param t T
    555 ---@param depth? number
    556 ---@return T
    557 function fb_utils.copy_table(t, depth)
    558     --this is to handle cyclic table references
    559     return copy_table_recursive(t, {}, depth or math.huge)
    560 end
    561 
    562 ---@alias Replacer fun(item: Item, s: State): (string|number|nil)
    563 ---@alias ReplacerTable table<string,Replacer>
    564 
    565 ---functions to replace custom-keybind codes
    566 ---@type ReplacerTable
    567 fb_utils.code_fns = {
    568     ["%"] = function() return "%" end,
    569 
    570     f = function(item, s) return item and fb_utils.get_full_path(item, s.directory) or "" end,
    571     n = function(item, s) return item and (item.label or item.name) or "" end,
    572     i = function(item, s)
    573             local i = fb_utils.list.indexOf(s.list, item)
    574             if #s.list == 0 then return 0 end
    575             return ('%0'..math.ceil(math.log10(#s.list))..'d'):format(i ~= -1 and i or 0)  ---@diagnostic disable-line deprecated
    576         end,
    577     j = function (item, s)
    578             return fb_utils.list.indexOf(s.list, item) ~= -1 and math.abs(fb_utils.list.indexOf( fb_utils.sort_keys(s.selection) , item)) or 0
    579         end,
    580     x = function(_, s) return #s.list or 0 end,
    581     p = function(_, s) return s.directory or "" end,
    582     q = function(_, s) return s.directory == '' and 'ROOT' or s.directory_label or s.directory or "" end,
    583     d = function(_, s) return (s.directory_label or s.directory):match("([^/]+)/?$") or "" end,
    584     r = function(_, s) return s.parser.keybind_name or s.parser.name or "" end,
    585 }
    586 
    587 ---Programatically creates a pattern that matches any key code.
    588 ---This will result in some duplicates but that shouldn't really matter.
    589 ---@param codes ReplacerTable
    590 ---@return string
    591 function fb_utils.get_code_pattern(codes)
    592     ---@type string
    593     local CUSTOM_KEYBIND_CODES = ""
    594     for key in pairs(codes) do CUSTOM_KEYBIND_CODES = CUSTOM_KEYBIND_CODES..key:lower()..key:upper() end
    595     for key in pairs((getmetatable(codes) or {}).__index or {} --[[@as ReplacerTable]]) do
    596         ---@type string
    597         CUSTOM_KEYBIND_CODES = CUSTOM_KEYBIND_CODES..key:lower()..key:upper()
    598     end
    599     return('%%%%([%s])'):format(fb_utils.pattern_escape(CUSTOM_KEYBIND_CODES))
    600 end
    601 
    602 ---Substitutes codes in the given string for other substrings.
    603 ---@param str string
    604 ---@param overrides? ReplacerTable Replacer functions for additional characters to match to after `%` characters.
    605 ---@param item? Item Uses the currently selected item if nil.
    606 ---@param state? State Uses the global state if nil.
    607 ---@param modifier_fn? fun(new_str: string, code: string): string given the replacement substrings before they are placed in the main string
    608 ---                                                 (the return value is the new replacement string).
    609 ---@return string
    610 function fb_utils.substitute_codes(str, overrides, item, state, modifier_fn)
    611     local replacers = overrides and setmetatable(fb_utils.copy_table(overrides), {__index = fb_utils.code_fns}) or fb_utils.code_fns
    612     item = item or g.state.list[g.state.selected]
    613     state = state or g.state
    614 
    615     return (string.gsub(str, fb_utils.get_code_pattern(replacers), function(code)
    616         ---@type string|number|nil
    617         local result
    618         local replacer = replacers[code]
    619 
    620         if type(replacer) == "string" then
    621             result = replacer
    622         --encapsulates the string if using an uppercase code
    623         elseif not replacer then
    624             local lower_fn = replacers[code:lower()]
    625             if not lower_fn then return end
    626             result = string.format("%q", lower_fn(item, state))
    627         else
    628             result = replacer(item, state)
    629         end
    630 
    631         if result and modifier_fn then return modifier_fn(tostring(result), code) end
    632         return result
    633     end))
    634 end
    635 
    636 
    637 return fb_utils