Lua's string.gsub function is very useful, but it only works with Lua patterns, which are restricted in what you can express. For pattern matching, my usual approach when Lua patterns are not expressive enough is to use LPeg. LPeg doesn't come with a drop-in replacement for string.gsub, but it is possible to construct an extended string.gsub which accepts LPeg patterns, as shown below:

local lpeg = require "lpeg"

local original_gsub = string.gsub
function string.gsub(s, patt, repl)
  if lpeg.type(patt) ~= "pattern" then
    -- If patt isn't an LPEG pattern, revert to the normal gsub
    return original_gsub(s, patt, repl)
  else
    -- Standardise repl to a function which takes the whole match
    -- as the first argument, and then subsequent matches (if
    -- there were any).
    local typ = type(repl)
    if typ == "table" then
      local t = repl
      repl = function(all, ...)
        if select('#', ...) == 0 then
          return t[all]
        else
          return t[...]
        end
      end
    elseif typ == "string" then
      local s = repl
      repl = function(...)
        local matches = {...}
        return s:gsub("%%([0-9%%])", function(c)
          if c == "%" then
            return "%"
          else
            return matches[tonumber(c) + 1]
          end
        end)
      end
    elseif typ == "function" then
      local f = repl
      repl = function(all, ...)
        if select('#', ...) == 0 then
          return f(all)
        else
          return f(...)
        end
      end
    else
      error("Expected table / string / function")
    end
    
    return lpeg.Cs{
      (lpeg.C(patt) / repl + 1) * lpeg.V(1) + true
    }:match(s)
  end
end