模組:PythonScript/Runtime/Regexp
外观
-- Module:PythonScript/Runtime/Regexp
-- 三路由混合式正則表達式引擎 (Three-Route Hybrid Regex Engine)
-- Route 1 (Fast-Path): 原生 Lua Pattern C 引擎單次直通 (含 (?#...), (?:...), \1~\9, 八進位跳脫)
-- Route 2 (CISC Slow-Path): 多階段動態拼接與鎖定匹配 (含 (?P=name), \1+, (?>...) 原子群組, \b/\B)
-- Route 3 (Micro-VM): 多分支調度 (|) 與前瞻/後顧斷言 ((?=...), (?!...), (?<=...), (?<!...)) + Bailout 安全閥
local Regexp = {}
local PyClass = require("Module:PythonScript/Class")
local Runtime = require("Module:PythonScript/Runtime")
local RUtils = require("Module:PythonScript/Runtime/Utils")
local ulen = (mw and mw.ustring and mw.ustring.len) or string.len
local usub = (mw and mw.ustring and mw.ustring.sub) or string.sub
local ufind = (mw and mw.ustring and mw.ustring.find) or string.find
local ulower = (mw and mw.ustring and mw.ustring.lower) or string.lower
local uchar = (mw and mw.ustring and mw.ustring.char) or string.char
local ubyte = (mw and mw.ustring and mw.ustring.codepoint) or string.byte
-- ==========================================
-- 1. 旗標常數與專用例外定義
-- ==========================================
local FLAG_NOFLAG = 0
local FLAG_IGNORECASE = 2
local FLAG_LOCALE = 4
local FLAG_MULTILINE = 8
local FLAG_DOTALL = 16
local FLAG_UNICODE = 32
local FLAG_VERBOSE = 64
local FLAG_ASCII = 256
local MAX_BRANCHES = 16
local MAX_ALT_DEPTH = 2
local MAX_REPEAT_COUNT = 200
local MAX_LOOKAROUNDS = 2
local ReErrorClass = nil
local MatchClass = nil
local PatternClass = nil
local compile_cache = {}
local function get_re_error_class()
if not ReErrorClass then
ReErrorClass = PyClass.make_class("error", { Runtime.builtins.Exception }, {})
ReErrorClass.__dict["__module__"] = "re"
end
return ReErrorClass
end
local function raise_re_error(msg)
Runtime.raise(get_re_error_class(), msg)
end
local function raise_bailout(msg)
Runtime.raise("NotImplementedError", msg)
end
local function is_none(v)
return v == nil or v == Runtime.builtins.None or (type(v) == "table" and v.__class and v.__class.__name == "NoneType")
end
local function has_flag(flags, f)
return math.floor((flags or 0) / f) % 2 == 1
end
local function unpack_pattern_input(pat)
if type(pat) == "table" then
if pat.__type == "bytes" or pat.__type == "bytearray" or pat.__type == "memoryview" then
return pat._data or "", true
elseif pat.__type == "instance" and pat.__builtin_value ~= nil then
return unpack_pattern_input(pat.__builtin_value)
end
end
if type(pat) == "string" then return pat, false end
Runtime.raise("TypeError", "first argument must be string or compiled pattern")
end
local function unpack_target_string(s, expect_bytes)
local is_b = false
local raw = s
if type(s) == "table" then
if s.__type == "bytes" or s.__type == "bytearray" or s.__type == "memoryview" then
raw = s._data or ""
is_b = true
elseif s.__type == "instance" and s.__builtin_value ~= nil then
return unpack_target_string(s.__builtin_value, expect_bytes)
else
Runtime.raise("TypeError", "expected string or bytes-like object")
end
elseif type(s) ~= "string" then
Runtime.raise("TypeError", "expected string or bytes-like object")
end
if expect_bytes ~= nil and expect_bytes ~= is_b then
Runtime.raise("TypeError", "cannot use a " .. (expect_bytes and "bytes" or "string") .. " pattern on a " .. (is_b and "bytes" or "string") .. " object")
end
return raw, is_b
end
local function wrap_output(str, is_bytes)
if str == nil then return Runtime.builtins.None end
if is_bytes then return { __type = "bytes", _data = str } end
return str
end
local function lua_escape_char(c)
if c == "\0" then return "%z" end
if c:find("^[%^%$%(%)%%%.%[%]%*%+%-%?]$") then
return "%" .. c
end
return c
end
local function lua_escape_str(s)
local out = {}
for i = 1, #s do
local c = s:sub(i, i)
table.insert(out, lua_escape_char(c))
end
return table.concat(out)
end
local function is_word_char(ch)
if not ch or ch == "" then return false end
return ufind(ch, "^[%w_]$") ~= nil
end
-- ==========================================
-- 2. 階段一:預處理器(剝除 (?#...) 註解、解析行內旗標與 VERBOSE)
-- ==========================================
local INLINE_FLAG_MAP = {
i = FLAG_IGNORECASE, L = FLAG_LOCALE, m = FLAG_MULTILINE,
s = FLAG_DOTALL, u = FLAG_UNICODE, x = FLAG_VERBOSE, a = FLAG_ASCII
}
local function preprocess_pattern(raw_pat, init_flags)
local flags = init_flags or 0
-- 先掃描頂層或開頭的 (?aiLmsux) 旗標以確認是否啟用 VERBOSE
local n = #raw_pat
local i = 1
local in_class = false
local pass1 = {}
while i <= n do
local c = raw_pat:sub(i, i)
if c == "\\" then
if i + 1 > n then raise_re_error("bad escape (end of pattern)") end
table.insert(pass1, raw_pat:sub(i, i + 1))
i = i + 2
elseif in_class then
if c == "]" then in_class = false end
table.insert(pass1, c)
i = i + 1
else
if c == "[" then
in_class = true
table.insert(pass1, "[")
i = i + 1
if i <= n and raw_pat:sub(i, i) == "^" then
table.insert(pass1, "^")
i = i + 1
end
if i <= n and raw_pat:sub(i, i) == "]" then
table.insert(pass1, "]")
i = i + 1
end
elseif c == "(" and raw_pat:sub(i + 1, i + 1) == "?" then
local sub2 = raw_pat:sub(i + 2, i + 2)
if sub2 == "#" then
-- (?#...) 註解:直接尋找未跳脫的 ')' 並剝除
local j = i + 3
while j <= n do
local cj = raw_pat:sub(j, j)
if cj == "\\" then j = j + 2
elseif cj == ")" then break
else j = j + 1 end
end
if j > n then raise_re_error("missing ), unterminated comment") end
i = j + 1
else
local f_str = raw_pat:sub(i + 2):match("^([aiLmsux]+)%)" )
if f_str then
for k = 1, #f_str do
local f_bit = INLINE_FLAG_MAP[f_str:sub(k, k)]
if f_bit and not has_flag(flags, f_bit) then flags = flags + f_bit end
end
i = i + 2 + #f_str + 1
else
table.insert(pass1, c)
i = i + 1
end
end
else
table.insert(pass1, c)
i = i + 1
end
end
end
if in_class then raise_re_error("unterminated character set") end
local cleaned = table.concat(pass1)
-- 若啟用 VERBOSE (re.X),剝除非跳脫空白與 # 行尾註解
if has_flag(flags, FLAG_VERBOSE) then
local out = {}
local len = #cleaned
local idx = 1
local in_cls = false
while idx <= len do
local ch = cleaned:sub(idx, idx)
if ch == "\\" then
table.insert(out, cleaned:sub(idx, idx + 1))
idx = idx + 2
elseif in_cls then
if ch == "]" then in_cls = false end
table.insert(out, ch)
idx = idx + 1
else
if ch == "[" then
in_cls = true
table.insert(out, "[")
idx = idx + 1
if idx <= len and cleaned:sub(idx, idx) == "^" then
table.insert(out, "^"); idx = idx + 1
end
if idx <= len and cleaned:sub(idx, idx) == "]" then
table.insert(out, "]"); idx = idx + 1
end
elseif ch == "#" then
while idx <= len and cleaned:sub(idx, idx) ~= "\n" do idx = idx + 1 end
idx = idx + 1
elseif ch:match("^%s$") then
idx = idx + 1
else
table.insert(out, ch)
idx = idx + 1
end
end
end
cleaned = table.concat(out)
end
return cleaned, flags
end
-- ==========================================
-- 3. 階段二:Regex AST 解析器與靜態分析器
-- ==========================================
local function parse_regex_ast(pat, flags)
local n = #pat
local pos = 1
local total_groups = 0
local group_index = {} -- name -> group_id
local closed_groups = {} -- group_id -> true
local max_alt_depth = 0
local lookaround_count = 0
local is_ignorecase = has_flag(flags, FLAG_IGNORECASE)
local is_dotall = has_flag(flags, FLAG_DOTALL)
local is_multiline = has_flag(flags, FLAG_MULTILINE)
local function parse_quantifier()
if pos > n then return 1, 1, true, false end
local c = pat:sub(pos, pos)
local min_rep, max_rep = 1, 1
local has_q = false
if c == "*" then
min_rep, max_rep, has_q = 0, math.huge, true
pos = pos + 1
elseif c == "+" then
min_rep, max_rep, has_q = 1, math.huge, true
pos = pos + 1
elseif c == "?" then
min_rep, max_rep, has_q = 0, 1, true
pos = pos + 1
elseif c == "{" then
local m_str, comma, n_str, end_pos = pat:sub(pos):match("^{(%d*)(,?)(%d*)}()")
if m_str and (m_str ~= "" or n_str ~= "") then
pos = pos + end_pos - 1
has_q = true
min_rep = (m_str ~= "") and tonumber(m_str) or 0
if comma == "" then
max_rep = min_rep
else
max_rep = (n_str ~= "") and tonumber(n_str) or math.huge
end
if max_rep < min_rep then
raise_re_error("min repeat greater than max repeat")
end
if min_rep > MAX_REPEAT_COUNT or (max_rep ~= math.huge and max_rep > MAX_REPEAT_COUNT) then
raise_bailout("Quantifier repeat count exceeds sandbox safety limit (" .. MAX_REPEAT_COUNT .. ")")
end
end
end
if not has_q then return 1, 1, true, false end
local greedy = true
if pos <= n and pat:sub(pos, pos) == "?" then
greedy = false
pos = pos + 1
elseif pos <= n and (pat:sub(pos, pos) == "+" or pat:sub(pos, pos) == "*") then
raise_re_error("multiple repeat")
end
return min_rep, max_rep, greedy, true
end
-- 解析 [...] 字元類別:裡面的所有數值跳脫 (\0, \7, \141 等) 一律視為八進位字元
local function parse_char_class()
pos = pos + 1 -- 跳過 '['
local out = { "[" }
if pos <= n and pat:sub(pos, pos) == "^" then
table.insert(out, "^")
pos = pos + 1
end
local first_in_class = true
while pos <= n do
local c = pat:sub(pos, pos)
if c == "]" and not first_in_class then
pos = pos + 1
table.insert(out, "]")
local raw_cls = table.concat(out)
if is_ignorecase then
-- 僅將非 % 跳脫的英文字母轉小寫
raw_cls = raw_cls:gsub("(%%?)(%a)", function(esc, ch)
if esc == "%" then return "%" .. ch end
return ch:lower()
end)
end
return { type = "Atom", lua_item = raw_cls }
end
first_in_class = false
if c == "\\" then
pos = pos + 1
if pos > n then raise_re_error("bad escape (end of pattern)") end
local ec = pat:sub(pos, pos)
if ec:match("^[0-7]$") then
-- 字元類別內的數值跳脫一律視為八進位字元 (最多 3 位)
local oct_s = pat:sub(pos):match("^([0-7][0-7]?[0-7]?)")
pos = pos + #oct_s
local val = tonumber(oct_s, 8) % 256
if val == 0 then table.insert(out, "%z")
else
local ch = uchar(val)
if ch == "]" or ch == "-" or ch == "^" or ch == "%" then
table.insert(out, "%" .. ch)
else
table.insert(out, ch)
end
end
elseif ec == "8" or ec == "9" then
raise_re_error("invalid octal escape \\" .. ec .. " inside character class")
elseif ec == "x" then
local hex_s = pat:sub(pos + 1, pos + 2)
if not hex_s:match("^%x%x$") then raise_re_error("incomplete \\x escape") end
pos = pos + 3
local val = tonumber(hex_s, 16)
if val == 0 then table.insert(out, "%z")
else
local ch = uchar(val)
if ch == "]" or ch == "-" or ch == "^" or ch == "%" then table.insert(out, "%" .. ch)
else table.insert(out, ch) end
end
elseif ec == "d" then table.insert(out, "%d"); pos = pos + 1
elseif ec == "D" then table.insert(out, "%D"); pos = pos + 1
elseif ec == "s" then table.insert(out, "%s"); pos = pos + 1
elseif ec == "S" then table.insert(out, "%S"); pos = pos + 1
elseif ec == "w" then table.insert(out, "%w_"); pos = pos + 1
elseif ec == "W" then table.insert(out, "%W"); pos = pos + 1
elseif ec == "n" then table.insert(out, "\n"); pos = pos + 1
elseif ec == "r" then table.insert(out, "\r"); pos = pos + 1
elseif ec == "t" then table.insert(out, "\t"); pos = pos + 1
elseif ec == "b" then table.insert(out, "\8"); pos = pos + 1
elseif ec == "f" then table.insert(out, "\12"); pos = pos + 1
elseif ec == "v" then table.insert(out, "\11"); pos = pos + 1
elseif ec == "a" then table.insert(out, "\7"); pos = pos + 1
else
if ec == "]" or ec == "-" or ec == "^" or ec == "%" then
table.insert(out, "%" .. ec)
else
table.insert(out, ec)
end
pos = pos + 1
end
else
if c == "%" then
table.insert(out, "%%")
elseif c == "-" and pat:sub(pos + 1, pos + 1) == "]" then
table.insert(out, "%-")
else
table.insert(out, c)
end
pos = pos + 1
end
end
raise_re_error("unterminated character set")
end
local parse_alt
local function parse_seq(in_lookaround, alt_depth)
local items = {}
while pos <= n do
local c = pat:sub(pos, pos)
if c == ")" or c == "|" then break end
local node = nil
local allow_quant = true
if c == "^" then
pos = pos + 1
node = { type = "Anchor", kind = is_multiline and "line_start" or "str_start" }
allow_quant = false
elseif c == "$" then
pos = pos + 1
node = { type = "Anchor", kind = is_multiline and "line_end" or "str_end" }
allow_quant = false
elseif c == "." then
pos = pos + 1
node = { type = "Atom", lua_item = is_dotall and "." or "[^\n]" }
elseif c == "[" then
node = parse_char_class()
elseif c == "(" then
pos = pos + 1
if pos > n then raise_re_error("missing ), unterminated subpattern") end
if pat:sub(pos, pos) == "?" then
pos = pos + 1
if pos > n then raise_re_error("unexpected end of pattern") end
local nc = pat:sub(pos, pos)
if nc == ":" then
pos = pos + 1
local body = parse_alt(in_lookaround, alt_depth)
if pos > n or pat:sub(pos, pos) ~= ")" then raise_re_error("missing ), unterminated subpattern") end
pos = pos + 1
node = { type = "Group", kind = "non_capture", body = body }
elseif nc == ">" then
pos = pos + 1
local body = parse_alt(in_lookaround, alt_depth)
if pos > n or pat:sub(pos, pos) ~= ")" then raise_re_error("missing ), unterminated atomic group") end
pos = pos + 1
node = { type = "Group", kind = "atomic", body = body }
elseif nc == "=" or nc == "!" then
if in_lookaround then
raise_bailout("Nested lookaround assertions are not supported in sandboxed hybrid regex engine")
end
lookaround_count = lookaround_count + 1
if lookaround_count > MAX_LOOKAROUNDS then
raise_bailout("Too many lookaround assertions in a single pattern")
end
pos = pos + 1
local body = parse_alt(true, alt_depth)
if pos > n or pat:sub(pos, pos) ~= ")" then raise_re_error("missing ), unterminated lookahead") end
pos = pos + 1
node = { type = "Lookaround", kind = (nc == "=") and "lookahead_pos" or "lookahead_neg", body = body }
allow_quant = false
elseif nc == "<" then
local nc2 = pat:sub(pos + 1, pos + 1)
if nc2 == "=" or nc2 == "!" then
if in_lookaround then
raise_bailout("Nested lookaround assertions are not supported in sandboxed hybrid regex engine")
end
lookaround_count = lookaround_count + 1
if lookaround_count > MAX_LOOKAROUNDS then
raise_bailout("Too many lookaround assertions in a single pattern")
end
pos = pos + 2
local body = parse_alt(true, alt_depth)
if pos > n or pat:sub(pos, pos) ~= ")" then raise_re_error("missing ), unterminated lookbehind") end
pos = pos + 1
node = { type = "Lookaround", kind = (nc2 == "=") and "lookbehind_pos" or "lookbehind_neg", body = body }
allow_quant = false
else
-- Python 3 亦支援 (?<name>...) 語法
local gname, next_p = pat:sub(pos + 1):match("^([%a_][%w_]*)>()")
if not gname then raise_re_error("unknown extension ?<") end
pos = pos + next_p
if group_index[gname] then raise_re_error("redefinition of group name '" .. gname .. "'") end
total_groups = total_groups + 1
local gid = total_groups
group_index[gname] = gid
local body = parse_alt(in_lookaround, alt_depth)
if pos > n or pat:sub(pos, pos) ~= ")" then raise_re_error("missing ), unterminated subpattern") end
pos = pos + 1
closed_groups[gid] = true
node = { type = "Group", kind = "capture", group_id = gid, name = gname, body = body }
end
elseif nc == "P" then
local p_mode = pat:sub(pos + 1, pos + 1)
if p_mode == "<" then
local gname, next_p = pat:sub(pos + 2):match("^([%a_][%w_]*)>()")
if not gname then raise_re_error("missing >, unterminated name") end
pos = pos + 1 + next_p
if group_index[gname] then raise_re_error("redefinition of group name '" .. gname .. "'") end
total_groups = total_groups + 1
local gid = total_groups
group_index[gname] = gid
local body = parse_alt(in_lookaround, alt_depth)
if pos > n or pat:sub(pos, pos) ~= ")" then raise_re_error("missing ), unterminated subpattern") end
pos = pos + 1
closed_groups[gid] = true
node = { type = "Group", kind = "capture", group_id = gid, name = gname, body = body }
elseif p_mode == "=" then
local gname, next_p = pat:sub(pos + 2):match("^([%a_][%w_]*)%)()")
if not gname then raise_re_error("missing ), unterminated name in (?P=name)") end
pos = pos + 1 + next_p
local target_gid = group_index[gname]
if not target_gid then
raise_re_error("unknown group name '" .. gname .. "'")
end
if not closed_groups[target_gid] then
raise_re_error("cannot refer to an open group")
end
node = { type = "Backref", group_id = target_gid, name = gname, is_named = true }
else
raise_re_error("unknown extension ?P" .. tostring(p_mode))
end
elseif nc == "(" then
raise_bailout("Conditional regex pattern (?(id)yes|no) is not supported in sandboxed hybrid engine")
else
raise_re_error("unknown extension ?" .. tostring(nc))
end
else
total_groups = total_groups + 1
local gid = total_groups
local body = parse_alt(in_lookaround, alt_depth)
if pos > n or pat:sub(pos, pos) ~= ")" then raise_re_error("missing ), unterminated subpattern") end
pos = pos + 1
closed_groups[gid] = true
node = { type = "Group", kind = "capture", group_id = gid, body = body }
end
elseif c == "\\" then
pos = pos + 1
if pos > n then raise_re_error("bad escape (end of pattern)") end
local ec = pat:sub(pos, pos)
if ec:match("^%d$") then
-- 區分八進位跳脫 (\0..., 或 3 位八進位數) 與群組反向參照 (\1 ~ \99)
local oct3 = pat:sub(pos):match("^([0-7][0-7][0-7])")
if ec == "0" then
local oct_z = pat:sub(pos):match("^([0-7][0-7]?[0-7]?)")
pos = pos + #oct_z
local val = tonumber(oct_z, 8) % 256
local ch = (val == 0) and "%z" or lua_escape_char(is_ignorecase and uchar(val):lower() or uchar(val))
node = { type = "Atom", lua_item = ch, raw_char = uchar(val) }
elseif oct3 then
pos = pos + 3
local val = tonumber(oct3, 8) % 256
local ch = (val == 0) and "%z" or lua_escape_char(is_ignorecase and uchar(val):lower() or uchar(val))
node = { type = "Atom", lua_item = ch, raw_char = uchar(val) }
else
local num2 = pat:sub(pos):match("^(%d%d?)")
pos = pos + #num2
local gid = tonumber(num2, 10)
if gid < 1 or gid > total_groups or not closed_groups[gid] then
raise_re_error("invalid group reference " .. num2)
end
node = { type = "Backref", group_id = gid, is_named = false }
end
elseif ec == "x" then
local hex_s = pat:sub(pos + 1, pos + 2)
if not hex_s:match("^%x%x$") then raise_re_error("incomplete \\x escape") end
pos = pos + 3
local val = tonumber(hex_s, 16)
local ch = (val == 0) and "%z" or lua_escape_char(is_ignorecase and uchar(val):lower() or uchar(val))
node = { type = "Atom", lua_item = ch, raw_char = uchar(val) }
elseif ec == "d" then pos = pos + 1; node = { type = "Atom", lua_item = "%d" }
elseif ec == "D" then pos = pos + 1; node = { type = "Atom", lua_item = "%D" }
elseif ec == "s" then pos = pos + 1; node = { type = "Atom", lua_item = "%s" }
elseif ec == "S" then pos = pos + 1; node = { type = "Atom", lua_item = "%S" }
elseif ec == "w" then pos = pos + 1; node = { type = "Atom", lua_item = "[%w_]" }
elseif ec == "W" then pos = pos + 1; node = { type = "Atom", lua_item = "[^%w_]" }
elseif ec == "A" then pos = pos + 1; node = { type = "Anchor", kind = "str_start" }; allow_quant = false
elseif ec == "Z" then pos = pos + 1; node = { type = "Anchor", kind = "str_end" }; allow_quant = false
elseif ec == "b" then pos = pos + 1; node = { type = "Boundary", kind = "b" }; allow_quant = false
elseif ec == "B" then pos = pos + 1; node = { type = "Boundary", kind = "B" }; allow_quant = false
elseif ec == "n" then pos = pos + 1; node = { type = "Atom", lua_item = "\n", raw_char = "\n" }
elseif ec == "r" then pos = pos + 1; node = { type = "Atom", lua_item = "\r", raw_char = "\r" }
elseif ec == "t" then pos = pos + 1; node = { type = "Atom", lua_item = "\t", raw_char = "\t" }
elseif ec == "f" then pos = pos + 1; node = { type = "Atom", lua_item = "\12", raw_char = "\12" }
elseif ec == "v" then pos = pos + 1; node = { type = "Atom", lua_item = "\11", raw_char = "\11" }
elseif ec == "a" then pos = pos + 1; node = { type = "Atom", lua_item = "\7", raw_char = "\7" }
elseif ec:match("^%a$") then
raise_re_error("bad escape \\" .. ec)
else
pos = pos + 1
local ch = is_ignorecase and ulower(ec) or ec
node = { type = "Atom", lua_item = lua_escape_char(ch), raw_char = ch }
end
elseif c == "*" or c == "+" or c == "?" then
raise_re_error("nothing to repeat")
else
-- 讀取單一 UTF-8 字元
local ch = usub(pat, 1, 1)
if pos > 1 then
local rem = pat:sub(pos)
ch = usub(rem, 1, 1)
end
pos = pos + #ch
local norm_ch = is_ignorecase and ulower(ch) or ch
node = { type = "Atom", lua_item = lua_escape_char(norm_ch), raw_char = norm_ch }
end
local min_rep, max_rep, greedy, has_q = parse_quantifier()
if has_q and not allow_quant then
raise_re_error("nothing to repeat")
end
node.min = min_rep
node.max = max_rep
node.greedy = greedy
node.has_q = has_q
table.insert(items, node)
end
return { type = "Seq", items = items }
end
parse_alt = function(in_lookaround, alt_depth)
local first_seq = parse_seq(in_lookaround, alt_depth)
if pos > n or pat:sub(pos, pos) ~= "|" then
return first_seq
end
local new_depth = alt_depth + 1
if new_depth > MAX_ALT_DEPTH then
raise_bailout("Alternation nesting depth (" .. new_depth .. ") exceeds sandbox limit (" .. MAX_ALT_DEPTH .. ")")
end
if new_depth > max_alt_depth then max_alt_depth = new_depth end
local branches = { first_seq }
while pos <= n and pat:sub(pos, pos) == "|" do
pos = pos + 1
table.insert(branches, parse_seq(in_lookaround, new_depth))
end
return { type = "Alt", branches = branches }
end
local root = parse_alt(false, 0)
if pos <= n then
if pat:sub(pos, pos) == ")" then
raise_re_error("unbalanced parenthesis")
end
raise_re_error("unexpected token at position " .. tostring(pos))
end
return {
root = root,
total_groups = total_groups,
group_index = group_index,
max_alt_depth = max_alt_depth,
lookaround_count = lookaround_count,
flags = flags
}
end
-- ==========================================
-- 4. 階段三:分支展開與三路由編譯器 (Route 1 / Route 2 / Route 3)
-- ==========================================
local function format_atom_quantifier(lua_item, min_r, max_r, greedy)
if min_r == 1 and max_r == 1 then return lua_item end
if min_r == 0 and max_r == math.huge then
return lua_item .. (greedy and "*" or "-")
elseif min_r == 1 and max_r == math.huge then
-- 關鍵修復:Lua 的 '-' 是 0 次以上惰性,因此 '+?' (1 次以上惰性) 必須轉為 XX-
if greedy then return lua_item .. "+"
else return lua_item .. lua_item .. "-" end
elseif min_r == 0 and max_r == 1 then
return lua_item .. "?"
else
local parts = {}
for _ = 1, min_r do table.insert(parts, lua_item) end
if max_r == math.huge then
table.insert(parts, lua_item .. (greedy and "*" or "-"))
else
for _ = min_r + 1, max_r do
table.insert(parts, lua_item .. "?")
end
end
return table.concat(parts)
end
end
-- 將 AST 展開為平坦的分支序列陣列 (每個分支為一條無 | 的指令列表)
local function expand_ast_branches(node)
if node.type == "Alt" then
local res = {}
for _, b in ipairs(node.branches) do
local sub_b = expand_ast_branches(b)
for _, sb in ipairs(sub_b) do
table.insert(res, sb)
if #res > MAX_BRANCHES then
raise_bailout("Total expanded regex alternation branches exceed sandbox limit (" .. MAX_BRANCHES .. ")")
end
end
end
return res
elseif node.type == "Seq" then
local branches = { {} }
for _, item in ipairs(node.items) do
if item.type == "Group" then
local g_min = item.min or 1
local g_max = item.max or 1
local inner_branches = expand_ast_branches(item.body)
if item.kind == "atomic" then
if g_min ~= 1 or g_max ~= 1 then
raise_bailout("Quantified atomic groups are not supported in sandboxed engine")
end
for _, br in ipairs(branches) do
table.insert(br, { type = "Atomic", branches = inner_branches })
end
elseif g_min == 1 and g_max == 1 then
local next_branches = {}
for _, prefix in ipairs(branches) do
for _, ib in ipairs(inner_branches) do
local combined = { unpack(prefix) }
if item.kind == "capture" then
table.insert(combined, { type = "CapOpen", group_id = item.group_id })
end
for _, tok in ipairs(ib) do table.insert(combined, tok) end
if item.kind == "capture" then
table.insert(combined, { type = "CapClose", group_id = item.group_id })
end
table.insert(next_branches, combined)
if #next_branches > MAX_BRANCHES then
raise_bailout("Total expanded regex alternation branches exceed sandbox limit (" .. MAX_BRANCHES .. ")")
end
end
end
branches = next_branches
elseif g_min == 0 and g_max == 1 and #inner_branches == 1 then
-- 可選群組 (?:...)? 或 (...)? 展開為 [有匹配, 空匹配] 兩分支
local next_branches = {}
local ib = inner_branches[1]
for _, prefix in ipairs(branches) do
local with_g = { unpack(prefix) }
if item.kind == "capture" then table.insert(with_g, { type = "CapOpen", group_id = item.group_id }) end
for _, tok in ipairs(ib) do table.insert(with_g, tok) end
if item.kind == "capture" then table.insert(with_g, { type = "CapClose", group_id = item.group_id }) end
local without_g = { unpack(prefix) }
if item.greedy ~= false then
table.insert(next_branches, with_g)
table.insert(next_branches, without_g)
else
table.insert(next_branches, without_g)
table.insert(next_branches, with_g)
end
if #next_branches > MAX_BRANCHES then
raise_bailout("Total expanded regex alternation branches exceed sandbox limit (" .. MAX_BRANCHES .. ")")
end
end
branches = next_branches
elseif #inner_branches == 1 and #inner_branches[1] == 1 and inner_branches[1][1].type == "Atom" and not inner_branches[1][1].has_q then
-- 單一 Atom 的重複群組 (如 (?:\d)+ 或 (\d)+)
local atom_tok = {
type = "Atom",
lua_item = inner_branches[1][1].lua_item,
min = g_min, max = g_max, greedy = item.greedy, has_q = true
}
for _, br in ipairs(branches) do
if item.kind == "capture" then table.insert(br, { type = "CapOpen", group_id = item.group_id }) end
table.insert(br, atom_tok)
if item.kind == "capture" then table.insert(br, { type = "CapClose", group_id = item.group_id }) end
end
elseif g_min == g_max and g_max <= 12 and #inner_branches == 1 then
-- 固定次數展開 (如 (?:ab){3})
local ib = inner_branches[1]
for _, br in ipairs(branches) do
for r_i = 1, g_max do
local record_cap = (item.kind == "capture" and r_i == g_max)
if record_cap then table.insert(br, { type = "CapOpen", group_id = item.group_id }) end
for _, tok in ipairs(ib) do table.insert(br, tok) end
if record_cap then table.insert(br, { type = "CapClose", group_id = item.group_id }) end
end
end
elseif #inner_branches == 1 then
-- 多元素重複群組 (如 (?:ab)+) -> 交由 Route 2 RepeatGroup 步驟處理
for _, br in ipairs(branches) do
table.insert(br, {
type = "RepeatGroup",
kind = item.kind,
group_id = item.group_id,
tokens = inner_branches[1],
min = g_min, max = g_max, greedy = item.greedy
})
end
else
raise_bailout("Quantified alternation group is too complex for sandboxed hybrid regex engine")
end
else
for _, br in ipairs(branches) do
table.insert(br, item)
end
end
end
return branches
end
return { { node } }
end
-- 將單一平坦 Token 序列編譯為 Lua Pattern (用於 Fast-Path 或子區段)
local function compile_tokens_to_lua(tokens, base_cap_map)
local pat_parts = {}
local cap_order = {} -- lua_cap_idx -> py_group_id
local py_to_lua_cap = {} -- py_group_id -> lua_cap_idx
if base_cap_map then
for k, v in pairs(base_cap_map) do py_to_lua_cap[k] = v end
end
local lua_cap_count = 0
for _, tok in ipairs(tokens) do
if tok.type == "Anchor" then
if tok.kind == "str_start" then table.insert(pat_parts, "^")
elseif tok.kind == "str_end" then table.insert(pat_parts, "$") end
elseif tok.type == "CapOpen" then
lua_cap_count = lua_cap_count + 1
cap_order[lua_cap_count] = tok.group_id
py_to_lua_cap[tok.group_id] = lua_cap_count
table.insert(pat_parts, "(")
elseif tok.type == "CapClose" then
table.insert(pat_parts, ")")
elseif tok.type == "Atom" then
table.insert(pat_parts, format_atom_quantifier(tok.lua_item, tok.min or 1, tok.max or 1, tok.greedy ~= false))
elseif tok.type == "Backref" then
local l_idx = py_to_lua_cap[tok.group_id]
if l_idx and l_idx <= 9 and (tok.min or 1) == 1 and (tok.max or 1) == 1 then
table.insert(pat_parts, "%" .. tostring(l_idx))
else
return nil, nil -- 需交由 Route 2 動態兩階段替換處理
end
else
return nil, nil
end
end
return table.concat(pat_parts), cap_order
end
-- 編譯單一分支為執行計畫 (Execution Plan)
local function compile_branch_plan(tokens, flags)
local n_tok = #tokens
local idx = 1
local lead_assertions = {}
while idx <= n_tok do
local t = tokens[idx]
if t.type == "Boundary" or (t.type == "Anchor" and t.kind == "line_start") then
table.insert(lead_assertions, t)
idx = idx + 1
elseif t.type == "Lookaround" and (t.kind == "lookbehind_pos" or t.kind == "lookbehind_neg") then
local lb_branches = expand_ast_branches(t.body)
local lb_pats = {}
for _, lbb in ipairs(lb_branches) do
for _, lbt in ipairs(lbb) do
if lbt.type == "Atom" and lbt.max == math.huge then
raise_bailout("Unbounded variable-length lookbehind is not supported in sandboxed engine")
end
end
local l_pat, l_caps = compile_tokens_to_lua(lbb)
if not l_pat then raise_bailout("Complex lookbehind is not supported in sandboxed engine") end
table.insert(lb_pats, { pat = l_pat .. "$", caps = l_caps })
end
table.insert(lead_assertions, { type = "Lookbehind", positive = (t.kind == "lookbehind_pos"), branches = lb_pats })
idx = idx + 1
else
break
end
end
local trail_assertions = {}
local end_idx = n_tok
while end_idx >= idx do
local t = tokens[end_idx]
if t.type == "Boundary" or (t.type == "Anchor" and t.kind == "line_end") then
table.insert(trail_assertions, 1, t)
end_idx = end_idx - 1
elseif t.type == "Lookaround" and (t.kind == "lookahead_pos" or t.kind == "lookahead_neg") then
local la_branches = expand_ast_branches(t.body)
local la_pats = {}
for _, lab in ipairs(la_branches) do
local l_pat, l_caps = compile_tokens_to_lua(lab)
if not l_pat then raise_bailout("Complex lookahead is not supported in sandboxed engine") end
table.insert(la_pats, { raw_pat = l_pat, anchored_pat = "^" .. l_pat, caps = l_caps })
end
table.insert(trail_assertions, 1, { type = "Lookahead", positive = (t.kind == "lookahead_pos"), branches = la_pats })
end_idx = end_idx - 1
else
break
end
end
local body_tokens = {}
local has_named_backref = false
local has_stage2_feature = false
for i = idx, end_idx do
local bt = tokens[i]
if bt.type == "Lookaround" then
raise_bailout("Mid-pattern lookaround assertion is too complex for sandboxed hybrid engine")
end
if bt.type == "Backref" and bt.is_named then has_named_backref = true end
if bt.type == "Atomic" or bt.type == "RepeatGroup" or bt.type == "Boundary" then
has_stage2_feature = true
end
table.insert(body_tokens, bt)
end
-- 嘗試直接編譯主體為單一 Lua Pattern
local fast_pat, fast_caps = compile_tokens_to_lua(body_tokens)
if fast_pat and not has_stage2_feature then
-- 最佳化:若結尾只有單一正前瞻 (?=la) 且無其他斷言,可融合為 (body)la 獲得 C 底層回溯能力
local fused_la = nil
if #trail_assertions == 1 and trail_assertions[1].type == "Lookahead" and trail_assertions[1].positive and #trail_assertions[1].branches == 1 then
local la_b = trail_assertions[1].branches[1]
if not fast_pat:sub(-1):match("%$$") and (#fast_caps + #la_b.caps + 1) <= 30 then
fused_la = {
pat = "(" .. fast_pat .. ")" .. la_b.raw_pat,
body_caps = fast_caps,
la_caps = la_b.caps
}
end
end
return {
mode = "fast_body",
lua_pat = fast_pat,
cap_order = fast_caps,
fused_la = fused_la,
lead_assertions = lead_assertions,
trail_assertions = trail_assertions,
has_named_backref = has_named_backref,
body_tokens = body_tokens
}
end
-- 否則拆解為分段微指令序列 (Segments:支援 Atomic (?>...), 量化 Backref \1+, RepeatGroup (?:ab)+, 中段 \b)
local segments = {}
local cur_chunk = {}
local function flush_chunk()
if #cur_chunk > 0 then
local c_pat, c_caps = compile_tokens_to_lua(cur_chunk)
if not c_pat then raise_bailout("Unsupported complex subpattern in sandboxed hybrid engine") end
table.insert(segments, { kind = "lua", pat = c_pat, anchored_pat = "^" .. c_pat, caps = c_caps })
cur_chunk = {}
end
end
for _, bt in ipairs(body_tokens) do
if bt.type == "Atomic" then
flush_chunk()
local atomic_pats = {}
for _, ab in ipairs(bt.branches) do
local a_pat, a_caps = compile_tokens_to_lua(ab)
if not a_pat then raise_bailout("Complex atomic group is not supported") end
table.insert(atomic_pats, { pat = a_pat, anchored_pat = "^" .. a_pat, caps = a_caps })
end
table.insert(segments, { kind = "atomic", branches = atomic_pats })
elseif bt.type == "Backref" then
flush_chunk()
table.insert(segments, {
kind = "backref",
group_id = bt.group_id,
min = bt.min or 1,
max = bt.max or 1,
greedy = bt.greedy ~= false
})
elseif bt.type == "RepeatGroup" then
flush_chunk()
local r_pat, r_caps = compile_tokens_to_lua(bt.tokens)
if not r_pat then raise_bailout("Complex repeated group is not supported") end
table.insert(segments, {
kind = "repeat_group",
group_id = (bt.kind == "capture") and bt.group_id or nil,
pat = r_pat,
anchored_pat = "^" .. r_pat,
caps = r_caps,
min = bt.min, max = bt.max, greedy = bt.greedy ~= false
})
elseif bt.type == "Boundary" then
flush_chunk()
table.insert(segments, { kind = "boundary", b_kind = bt.kind })
else
table.insert(cur_chunk, bt)
end
end
flush_chunk()
return {
mode = "segmented",
segments = segments,
lead_assertions = lead_assertions,
trail_assertions = trail_assertions,
has_named_backref = has_named_backref
}
end
local function compile_regex(raw_pat, init_flags, is_bytes)
local cache_key = tostring(is_bytes) .. "|" .. tostring(init_flags or 0) .. "|" .. raw_pat
if compile_cache[cache_key] then return compile_cache[cache_key] end
local cleaned, flags = preprocess_pattern(raw_pat, init_flags)
local ast_info = parse_regex_ast(cleaned, flags)
local raw_branches = expand_ast_branches(ast_info.root)
local branch_plans = {}
local route = 1
if #raw_branches > 1 or ast_info.lookaround_count > 0 then
route = 3
end
for _, rb in ipairs(raw_branches) do
local plan = compile_branch_plan(rb, flags)
table.insert(branch_plans, plan)
if route == 1 then
if plan.mode == "segmented" or plan.has_named_backref or #plan.lead_assertions > 0 or #plan.trail_assertions > 0 then
route = 2
end
end
end
local compiled = {
raw_pattern = raw_pat,
flags = flags,
is_bytes = is_bytes,
is_ignorecase = has_flag(flags, FLAG_IGNORECASE),
total_groups = ast_info.total_groups,
group_index = ast_info.group_index,
branches = branch_plans,
route = route
}
compile_cache[cache_key] = compiled
return compiled
end
-- ==========================================
-- 5. 階段四:三路由執行引擎與斷言驗證器
-- ==========================================
local function check_boundary_at(orig_s, pos, len, b_kind)
local prev_c = (pos > 1) and usub(orig_s, pos - 1, pos - 1) or ""
local next_c = (pos <= len) and usub(orig_s, pos, pos) or ""
local is_wb = (is_word_char(prev_c) ~= is_word_char(next_c))
if b_kind == "b" then return is_wb else return not is_wb end
end
local function check_lead_assertions(assertions, search_s, orig_s, start_pos, len, caps_out)
for _, a in ipairs(assertions) do
if a.type == "Anchor" and a.kind == "line_start" then
if start_pos > 1 and usub(orig_s, start_pos - 1, start_pos - 1) ~= "\n" then return false end
elseif a.type == "Boundary" then
if not check_boundary_at(orig_s, start_pos, len, a.kind) then return false end
elseif a.type == "Lookbehind" then
local prefix = (start_pos > 1) and usub(search_s, 1, start_pos - 1) or ""
local matched_lb = false
for _, lbb in ipairs(a.branches) do
local results = { ufind(prefix, lbb.pat) }
if results[1] then
matched_lb = true
if a.positive and lbb.caps then
for c_i, gid in ipairs(lbb.caps) do
caps_out[gid] = results[c_i + 2]
end
end
break
end
end
if a.positive ~= matched_lb then return false end
end
end
return true
end
local function check_trail_assertions(assertions, search_s, orig_s, end_pos, len, caps_out)
local next_pos = end_pos + 1
for _, a in ipairs(assertions) do
if a.type == "Anchor" and a.kind == "line_end" then
if end_pos < len and usub(orig_s, next_pos, next_pos) ~= "\n" then return false end
elseif a.type == "Boundary" then
if not check_boundary_at(orig_s, next_pos, len, a.kind) then return false end
elseif a.type == "Lookahead" then
local suffix = (next_pos <= len) and usub(search_s, next_pos) or ""
local matched_la = false
for _, lab in ipairs(a.branches) do
local results = { ufind(suffix, lab.anchored_pat) }
if results[1] then
matched_la = true
if a.positive and lab.caps then
for c_i, gid in ipairs(lab.caps) do
caps_out[gid] = results[c_i + 2]
end
end
break
end
end
if a.positive ~= matched_la then return false end
end
end
return true
end
-- 執行單一分支於指定範圍 [search_pos, endpos]
local function execute_branch(plan, search_s, orig_s, search_pos, len, anchored_start, anchored_full)
-- [A] Fast-Body 模式 (Route 1 直通,或帶邊界斷言的單段 Pattern)
if plan.mode == "fast_body" then
-- 若已融合正前瞻 (body)la 且非 fullmatch
if plan.fused_la and not anchored_full and #plan.lead_assertions == 0 then
local f_pat = anchored_start and ("^" .. plan.fused_la.pat) or plan.fused_la.pat
local res = { ufind(search_s, f_pat, search_pos) }
if not res[1] then return nil end
local s_idx = res[1]
local body_str = res[3] or ""
local e_idx = s_idx + ulen(body_str) - 1
local caps = {}
local b_caps = plan.fused_la.body_caps
for i, gid in ipairs(b_caps) do
caps[gid] = res[i + 3]
end
local offset = 3 + #b_caps
for i, gid in ipairs(plan.fused_la.la_caps) do
caps[gid] = res[offset + i]
end
return s_idx, e_idx, caps
end
local cur_pos = search_pos
local pat_to_use = plan.lua_pat
if anchored_start then pat_to_use = "^" .. pat_to_use end
if anchored_full and #plan.trail_assertions == 0 then pat_to_use = pat_to_use .. "$" end
while cur_pos <= len + 1 do
local res = { ufind(search_s, pat_to_use, cur_pos) }
if not res[1] then return nil end
local s_idx, e_idx = res[1], res[2]
local lead_caps = {}
if check_lead_assertions(plan.lead_assertions, search_s, orig_s, s_idx, len, lead_caps) then
-- 檢查尾部斷言;若失敗且非原子匹配,嘗試退位回溯 e_try = e_idx - 1 .. s_idx
local min_e = (#plan.trail_assertions > 0) and (s_idx - 1) or e_idx
for e_try = e_idx, min_e, -1 do
local try_res = res
local sub_ok = true
if e_try < e_idx then
local slice_s = usub(search_s, s_idx, e_try)
try_res = { ufind(slice_s, "^" .. plan.lua_pat .. "$") }
if not try_res[1] then sub_ok = false end
end
if sub_ok and (not anchored_full or e_try == len) then
local trail_caps = {}
if check_trail_assertions(plan.trail_assertions, search_s, orig_s, e_try, len, trail_caps) then
local caps = {}
for k, v in pairs(lead_caps) do caps[k] = v end
for i, gid in ipairs(plan.cap_order) do
local c_val = try_res[i + 2]
if type(c_val) == "string" then
caps[gid] = c_val
end
end
for k, v in pairs(trail_caps) do caps[k] = v end
return s_idx, e_try, caps
end
end
end
end
if anchored_start or pat_to_use:sub(1, 1) == "^" then return nil end
cur_pos = s_idx + 1
end
return nil
end
-- [B] Segmented 多階段微指令模式 (Route 2 / Route 3:含 Atomic (?>...), 量化 Backref, RepeatGroup)
local segs = plan.segments
local n_segs = #segs
local function match_seg_from(seg_idx, cur_cursor, caps)
if seg_idx > n_segs then
if anchored_full and (cur_cursor - 1) ~= len then return nil end
local trail_caps = {}
if check_trail_assertions(plan.trail_assertions, search_s, orig_s, cur_cursor - 1, len, trail_caps) then
for k, v in pairs(trail_caps) do caps[k] = v end
return cur_cursor - 1
end
return nil
end
local seg = segs[seg_idx]
local rem_s = (cur_cursor <= len) and usub(search_s, cur_cursor) or ""
if seg.kind == "boundary" then
if check_boundary_at(orig_s, cur_cursor, len, seg.b_kind) then
return match_seg_from(seg_idx + 1, cur_cursor, caps)
end
return nil
elseif seg.kind == "atomic" then
-- 原子群組 (?>...):一旦匹配成功即鎖定游標,絕不回溯至內部
for _, ab in ipairs(seg.branches) do
local res = { ufind(rem_s, ab.anchored_pat) }
if res[1] then
local saved = {}
for k, v in pairs(caps) do saved[k] = v end
for i, gid in ipairs(ab.caps) do caps[gid] = res[i + 2] end
local next_cur = cur_cursor + res[2]
return match_seg_from(seg_idx + 1, next_cur, caps)
end
end
return nil
elseif seg.kind == "backref" then
-- 階段二動態反向參照匹配 (含 \1+, (?P=name)+ 等量詞支援)
local ref_val = caps[seg.group_id]
if not ref_val then return nil end
local ref_len = ulen(ref_val)
if ref_len == 0 then
return match_seg_from(seg_idx + 1, cur_cursor, caps)
end
local max_possible = 0
local scan_c = cur_cursor
while max_possible < seg.max and (scan_c + ref_len - 1) <= len do
if usub(search_s, scan_c, scan_c + ref_len - 1) == ref_val then
max_possible = max_possible + 1
scan_c = scan_c + ref_len
else
break
end
end
if max_possible < seg.min then return nil end
local start_k = seg.greedy and max_possible or seg.min
local end_k = seg.greedy and seg.min or max_possible
local step_k = seg.greedy and -1 or 1
for k = start_k, end_k, step_k do
local saved = {}
for g_k, g_v in pairs(caps) do saved[g_k] = g_v end
local fin = match_seg_from(seg_idx + 1, cur_cursor + k * ref_len, saved)
if fin then
for g_k, g_v in pairs(saved) do caps[g_k] = g_v end
return fin
end
end
return nil
elseif seg.kind == "repeat_group" then
local positions = { [0] = { cursor = cur_cursor, caps = {} } }
for k, v in pairs(caps) do positions[0].caps[k] = v end
local count = 0
local scan_c = cur_cursor
while count < seg.max and scan_c <= len do
local sub_rem = usub(search_s, scan_c)
local res = { ufind(sub_rem, seg.anchored_pat) }
if not res[1] or res[2] == 0 then break end
count = count + 1
scan_c = scan_c + res[2]
local snap_caps = {}
for k, v in pairs(positions[count - 1].caps) do snap_caps[k] = v end
if seg.group_id then
snap_caps[seg.group_id] = usub( sub_rem, 1, res[2] )
end
for i, gid in ipairs(seg.caps) do snap_caps[gid] = res[i + 2] end
positions[count] = { cursor = scan_c, caps = snap_caps }
end
if count < seg.min then return nil end
local start_k = seg.greedy and count or seg.min
local end_k = seg.greedy and seg.min or count
local step_k = seg.greedy and -1 or 1
for k = start_k, end_k, step_k do
local try_caps = {}
for gk, gv in pairs(positions[k].caps) do try_caps[gk] = gv end
local fin = match_seg_from(seg_idx + 1, positions[k].cursor, try_caps)
if fin then
for gk, gv in pairs(try_caps) do caps[gk] = gv end
return fin
end
end
return nil
elseif seg.kind == "lua" then
local res = { ufind(rem_s, seg.anchored_pat) }
if not res[1] then return nil end
local max_e = res[2]
-- 優先嘗試最長匹配,若後續段失敗則逐步回溯
for try_len = max_e, 0, -1 do
local try_res = res
local ok = true
if try_len < max_e then
local slice_rem = usub(rem_s, 1, try_len)
try_res = { ufind(slice_rem, seg.anchored_pat .. "$") }
if not try_res[1] then ok = false end
end
if ok then
local try_caps = {}
for gk, gv in pairs(caps) do try_caps[gk] = gv end
for i, gid in ipairs(seg.caps) do try_caps[gid] = try_res[i + 2] end
local fin = match_seg_from(seg_idx + 1, cur_cursor + try_len, try_caps)
if fin then
for gk, gv in pairs(try_caps) do caps[gk] = gv end
return fin
end
end
end
return nil
end
return nil
end
local start_p = search_pos
local end_p = anchored_start and search_pos or (len + 1)
local first_seg = segs[1]
while start_p <= end_p do
-- 加速:若第一段是標準 Lua pattern 或 Atomic 且無前置斷言,先用 ufind 快速跳躍至候選點
if not anchored_start and #plan.lead_assertions == 0 and first_seg then
if first_seg.kind == "lua" then
local s_jump = ufind(search_s, first_seg.pat, start_p)
if not s_jump then return nil end
start_p = s_jump
elseif first_seg.kind == "atomic" and #first_seg.branches == 1 then
local s_jump = ufind(search_s, first_seg.branches[1].pat, start_p)
if not s_jump then return nil end
start_p = s_jump
end
end
local caps = {}
if check_lead_assertions(plan.lead_assertions, search_s, orig_s, start_p, len, caps) then
local fin_e = match_seg_from(1, start_p, caps)
if fin_e then
return start_p, fin_e, caps
end
end
if anchored_start then return nil end
start_p = start_p + 1
end
return nil
end
-- 頂層調度器:在所有分支中依據 Python Leftmost-First 規則選出最佳匹配
local function exec_compiled_pattern(compiled, orig_s, pos, endpos, mode)
local full_len = ulen(orig_s)
local s_pos = (pos and pos > 0) and (pos + 1) or 1
local e_pos = (endpos and endpos < full_len) and endpos or full_len
if s_pos > e_pos + 1 then return nil end
local work_orig = (e_pos < full_len) and usub(orig_s, 1, e_pos) or orig_s
local work_search = compiled.is_ignorecase and ulower(work_orig) or work_orig
local work_len = e_pos
local anchored_start = (mode == "match" or mode == "fullmatch")
local anchored_full = (mode == "fullmatch")
local best_s, best_e, best_caps = nil, nil, nil
for _, plan in ipairs(compiled.branches) do
local m_s, m_e, m_caps = execute_branch(plan, work_search, work_orig, s_pos, work_len, anchored_start, anchored_full)
if m_s then
if not best_s or m_s < best_s then
best_s, best_e, best_caps = m_s, m_e, m_caps
if anchored_start then break end
end
end
end
if not best_s then return nil end
-- 若啟用 IGNORECASE,將捕獲到的子字串還原為原字串對應位置的真實大小寫,並計算各群組 span
local final_caps = {}
local group_spans = { [0] = { best_s - 1, best_e } }
local match_slice_search = usub(work_search, best_s, best_e)
local search_cursor = 1
for gid = 1, compiled.total_groups do
local c_val = best_caps[gid]
if c_val ~= nil then
local c_len = ulen(c_val)
local found_rel = ufind(match_slice_search, c_val, search_cursor, true)
if not found_rel then
found_rel = ufind(match_slice_search, c_val, 1, true)
end
if found_rel then
local abs_s = best_s + found_rel - 1
local abs_e = abs_s + c_len - 1
group_spans[gid] = { abs_s - 1, abs_e }
final_caps[gid] = usub(orig_s, abs_s, abs_e)
search_cursor = found_rel + math.max(1, c_len)
else
final_caps[gid] = c_val
group_spans[gid] = { -1, -1 }
end
else
group_spans[gid] = { -1, -1 }
end
end
return {
start_idx = best_s - 1,
end_idx = best_e,
match_str = usub(orig_s, best_s, best_e),
captures = final_caps,
spans = group_spans,
pos = pos or 0,
endpos = e_pos,
orig_s = orig_s
}
end
-- ==========================================
-- 6. Match 與 Pattern 物件模型封裝
-- ==========================================
local function expand_replacement_template(tmpl, m_data, compiled)
local out = {}
local i = 1
local n = #tmpl
while i <= n do
local c = tmpl:sub(i, i)
if c == "\\" and i < n then
local nc = tmpl:sub(i + 1, i + 1)
if nc == "g" and tmpl:sub(i + 2, i + 2) == "<" then
local g_ref, next_p = tmpl:sub(i + 3):match("^([^>]+)>()")
if not g_ref then raise_re_error("missing >, unterminated group name in replacement") end
i = i + 2 + next_p
local gid = tonumber(g_ref, 10) or compiled.group_index[g_ref]
if gid == 0 then
table.insert(out, m_data.match_str or "")
elseif gid and gid >= 1 and gid <= compiled.total_groups then
table.insert(out, m_data.captures[gid] or "")
else
raise_re_error("invalid group reference '" .. g_ref .. "' in replacement")
end
elseif nc:match("^%d$") then
local num_s = tmpl:sub(i + 1):match("^(%d%d?)")
local gid = tonumber(num_s, 10)
if gid > compiled.total_groups and #num_s == 2 then
num_s = num_s:sub(1, 1)
gid = tonumber(num_s, 10)
end
i = i + 1 + #num_s
if gid == 0 then
table.insert(out, "\0")
elseif gid >= 1 and gid <= compiled.total_groups then
table.insert(out, m_data.captures[gid] or "")
else
raise_re_error("invalid group reference \\" .. num_s)
end
elseif nc == "n" then table.insert(out, "\n"); i = i + 2
elseif nc == "t" then table.insert(out, "\t"); i = i + 2
elseif nc == "r" then table.insert(out, "\r"); i = i + 2
elseif nc == "\\" then table.insert(out, "\\"); i = i + 2
else
table.insert(out, "\\" .. nc); i = i + 2
end
else
table.insert(out, c)
i = i + 1
end
end
return table.concat(out)
end
local function make_match_object(m_data, pat_inst, compiled)
if not MatchClass then
local function resolve_group_arg(self, g_arg)
local comp = self.__dict._compiled
local md = self.__dict._mdata
if g_arg == nil or g_arg == 0 then
return 0, md.match_str
end
if type(g_arg) == "table" and g_arg.__type == "int" then
g_arg = RUtils.unwrap_num(g_arg)
end
if type(g_arg) == "number" and math.floor(g_arg) == g_arg then
if g_arg < 0 or g_arg > comp.total_groups then
Runtime.raise("IndexError", "no such group")
end
if g_arg == 0 then return 0, md.match_str end
return g_arg, md.captures[g_arg]
elseif type(g_arg) == "string" then
local gid = comp.group_index[g_arg]
if not gid then Runtime.raise("IndexError", "no such group") end
return gid, md.captures[gid]
end
Runtime.raise("IndexError", "no such group")
end
local match_dict = {
group = {
__type = "builtin_method", __name = "group",
__call = function(args)
local self = args[1]
local is_b = self.__dict._compiled.is_bytes
if #args <= 1 then
local _, val = resolve_group_arg(self, 0)
return wrap_output(val, is_b)
elseif #args == 2 then
local _, val = resolve_group_arg(self, args[2])
return wrap_output(val, is_b)
else
local elts = {}
for i = 2, #args do
local _, val = resolve_group_arg(self, args[i])
table.insert(elts, wrap_output(val, is_b))
end
return { __type = "tuple", elements = elts }
end
end
},
__getitem__ = {
__type = "builtin_method", __name = "__getitem__",
__call = function(args)
local self = args[1]
local _, val = resolve_group_arg(self, args[2])
return wrap_output(val, self.__dict._compiled.is_bytes)
end
},
groups = {
__type = "builtin_method", __name = "groups",
__call = function(args, kwargs)
local self = args[1]
local default_val = (args[2] ~= nil and args[2]) or (kwargs and kwargs.default) or Runtime.builtins.None
local comp = self.__dict._compiled
local md = self.__dict._mdata
local elts = {}
for i = 1, comp.total_groups do
local v = md.captures[i]
if v == nil then table.insert(elts, default_val)
else table.insert(elts, wrap_output(v, comp.is_bytes)) end
end
return { __type = "tuple", elements = elts }
end
},
groupdict = {
__type = "builtin_method", __name = "groupdict",
__call = function(args, kwargs)
local self = args[1]
local default_val = (args[2] ~= nil and args[2]) or (kwargs and kwargs.default) or Runtime.builtins.None
local comp = self.__dict._compiled
local md = self.__dict._mdata
local kv_pairs = {}
for name, gid in pairs(comp.group_index) do
local v = md.captures[gid]
local out_v = (v == nil) and default_val or wrap_output(v, comp.is_bytes)
table.insert(kv_pairs, { key = name, value = out_v })
end
return { __type = "dict", pairs = kv_pairs }
end
},
start = {
__type = "builtin_method", __name = "start",
__call = function(args)
local self = args[1]
local gid = resolve_group_arg(self, args[2] or 0)
local sp = self.__dict._mdata.spans[gid]
return sp and sp[1] or -1
end
},
["end"] = {
__type = "builtin_method", __name = "end",
__call = function(args)
local self = args[1]
local gid = resolve_group_arg(self, args[2] or 0)
local sp = self.__dict._mdata.spans[gid]
return sp and sp[2] or -1
end
},
span = {
__type = "builtin_method", __name = "span",
__call = function(args)
local self = args[1]
local gid = resolve_group_arg(self, args[2] or 0)
local sp = self.__dict._mdata.spans[gid] or { -1, -1 }
return { __type = "tuple", elements = { sp[1], sp[2] } }
end
},
expand = {
__type = "builtin_method", __name = "expand",
__call = function(args)
local self = args[1]
local tmpl = unpack_pattern_input(args[2])
local res = expand_replacement_template(tmpl, self.__dict._mdata, self.__dict._compiled)
return wrap_output(res, self.__dict._compiled.is_bytes)
end
},
__bool__ = { __type = "builtin_method", __call = function() return true end },
__repr__ = {
__type = "builtin_method",
__call = function(args)
local md = args[1].__dict._mdata
return string.format("<re.Match object; span=(%d, %d), match=%s>",
md.start_idx, md.end_idx, RUtils.to_str(wrap_output(md.match_str, args[1].__dict._compiled.is_bytes), true))
end
}
}
match_dict.__str__ = match_dict.__repr__
MatchClass = PyClass.make_class("Match", { Runtime.builtins.object }, match_dict)
MatchClass.__dict["__module__"] = "re"
end
local inst = PyClass.make_instance(MatchClass)
inst.__dict._mdata = m_data
inst.__dict._compiled = compiled
inst.__dict.pos = m_data.pos
inst.__dict.endpos = m_data.endpos
inst.__dict.re = pat_inst
inst.__dict["string"] = wrap_output(m_data.orig_s, compiled.is_bytes)
local last_idx = Runtime.builtins.None
local last_grp = Runtime.builtins.None
for gid = compiled.total_groups, 1, -1 do
if m_data.captures[gid] ~= nil then
last_idx = gid
for name, id_val in pairs(compiled.group_index) do
if id_val == gid then last_grp = name; break end
end
break
end
end
inst.__dict.lastindex = last_idx
inst.__dict.lastgroup = last_grp
return inst
end
local function make_pattern_object(compiled)
if not PatternClass then
local function parse_pos_bounds(args, kwargs, full_len)
local pos = (args[3] ~= nil and not is_none(args[3])) and RUtils.unwrap_num(args[3]) or (kwargs and kwargs.pos and RUtils.unwrap_num(kwargs.pos)) or 0
local endpos = (args[4] ~= nil and not is_none(args[4])) and RUtils.unwrap_num(args[4]) or (kwargs and kwargs.endpos and RUtils.unwrap_num(kwargs.endpos)) or full_len
return math.max(0, pos), math.min(full_len, endpos)
end
local function subn_impl(self, repl, target_in, count)
local comp = self.__dict._compiled
local raw_s = unpack_target_string(target_in, comp.is_bytes)
local full_len = ulen(raw_s)
local max_count = (count and not is_none(count)) and RUtils.unwrap_num(count) or 0
if max_count < 0 then max_count = 0 end
local is_callable = Runtime.builtins.callable({ repl })
local repl_tmpl = nil
if not is_callable then
repl_tmpl = unpack_target_string(repl, comp.is_bytes)
end
local out_parts = {}
local last_end = 0
local search_pos = 0
local n_subs = 0
local Evaluator = is_callable and require("Module:PythonScript/Evaluator") or nil
while search_pos <= full_len do
if max_count > 0 and n_subs >= max_count then break end
local m_data = exec_compiled_pattern(comp, raw_s, search_pos, full_len, "search")
if not m_data then break end
if m_data.start_idx > last_end then
table.insert(out_parts, usub(raw_s, last_end + 1, m_data.start_idx))
end
local rep_piece
if is_callable then
local m_obj = make_match_object(m_data, self, comp)
local r_val = Evaluator.invoke(repl, { m_obj }, {})
rep_piece = unpack_target_string(r_val, comp.is_bytes)
else
rep_piece = expand_replacement_template(repl_tmpl, m_data, comp)
end
table.insert(out_parts, rep_piece)
n_subs = n_subs + 1
last_end = m_data.end_idx
if m_data.end_idx == m_data.start_idx then
if m_data.end_idx < full_len then
table.insert(out_parts, usub(raw_s, m_data.end_idx + 1, m_data.end_idx + 1))
end
last_end = m_data.end_idx + 1
search_pos = m_data.end_idx + 1
else
search_pos = m_data.end_idx
end
end
if last_end <= full_len then
table.insert(out_parts, usub(raw_s, last_end + 1))
end
return wrap_output(table.concat(out_parts), comp.is_bytes), n_subs
end
local pat_dict = {
search = {
__type = "builtin_method", __name = "search",
__call = function(args, kwargs)
local self = args[1]
local comp = self.__dict._compiled
local raw_s = unpack_target_string(args[2], comp.is_bytes)
local pos, endpos = parse_pos_bounds(args, kwargs, ulen(raw_s))
local m_data = exec_compiled_pattern(comp, raw_s, pos, endpos, "search")
if not m_data then return Runtime.builtins.None end
return make_match_object(m_data, self, comp)
end
},
match = {
__type = "builtin_method", __name = "match",
__call = function(args, kwargs)
local self = args[1]
local comp = self.__dict._compiled
local raw_s = unpack_target_string(args[2], comp.is_bytes)
local pos, endpos = parse_pos_bounds(args, kwargs, ulen(raw_s))
local m_data = exec_compiled_pattern(comp, raw_s, pos, endpos, "match")
if not m_data then return Runtime.builtins.None end
return make_match_object(m_data, self, comp)
end
},
fullmatch = {
__type = "builtin_method", __name = "fullmatch",
__call = function(args, kwargs)
local self = args[1]
local comp = self.__dict._compiled
local raw_s = unpack_target_string(args[2], comp.is_bytes)
local pos, endpos = parse_pos_bounds(args, kwargs, ulen(raw_s))
local m_data = exec_compiled_pattern(comp, raw_s, pos, endpos, "fullmatch")
if not m_data then return Runtime.builtins.None end
return make_match_object(m_data, self, comp)
end
},
findall = {
__type = "builtin_method", __name = "findall",
__call = function(args, kwargs)
local self = args[1]
local comp = self.__dict._compiled
local raw_s = unpack_target_string(args[2], comp.is_bytes)
local pos, endpos = parse_pos_bounds(args, kwargs, ulen(raw_s))
local results = {}
local cur_pos = pos
while cur_pos <= endpos do
local m_data = exec_compiled_pattern(comp, raw_s, cur_pos, endpos, "search")
if not m_data then break end
if comp.total_groups == 0 then
table.insert(results, wrap_output(m_data.match_str, comp.is_bytes))
elseif comp.total_groups == 1 then
table.insert(results, wrap_output(m_data.captures[1] or "", comp.is_bytes))
else
local tup = {}
for g = 1, comp.total_groups do
table.insert(tup, wrap_output(m_data.captures[g] or "", comp.is_bytes))
end
table.insert(results, { __type = "tuple", elements = tup })
end
if m_data.end_idx == m_data.start_idx then
cur_pos = m_data.end_idx + 1
else
cur_pos = m_data.end_idx
end
end
return { __type = "list", elements = results }
end
},
finditer = {
__type = "builtin_method", __name = "finditer",
__call = function(args, kwargs)
local self = args[1]
local comp = self.__dict._compiled
local raw_s = unpack_target_string(args[2], comp.is_bytes)
local pos, endpos = parse_pos_bounds(args, kwargs, ulen(raw_s))
local matches = {}
local cur_pos = pos
while cur_pos <= endpos do
local m_data = exec_compiled_pattern(comp, raw_s, cur_pos, endpos, "search")
if not m_data then break end
table.insert(matches, make_match_object(m_data, self, comp))
if m_data.end_idx == m_data.start_idx then
cur_pos = m_data.end_idx + 1
else
cur_pos = m_data.end_idx
end
end
return Runtime.builtins.iter({ { __type = "list", elements = matches } })
end
},
split = {
__type = "builtin_method", __name = "split",
__call = function(args, kwargs)
local self = args[1]
local comp = self.__dict._compiled
local raw_s = unpack_target_string(args[2], comp.is_bytes)
local maxsplit = (args[3] ~= nil and not is_none(args[3])) and RUtils.unwrap_num(args[3]) or (kwargs and kwargs.maxsplit and RUtils.unwrap_num(kwargs.maxsplit)) or 0
local full_len = ulen(raw_s)
local parts = {}
local last_end = 0
local search_pos = 0
local n_splits = 0
while search_pos <= full_len do
if maxsplit > 0 and n_splits >= maxsplit then break end
local m_data = exec_compiled_pattern(comp, raw_s, search_pos, full_len, "search")
if not m_data then break end
if m_data.end_idx == m_data.start_idx and m_data.start_idx == last_end then
search_pos = search_pos + 1
else
table.insert(parts, wrap_output(usub(raw_s, last_end + 1, m_data.start_idx), comp.is_bytes))
for g = 1, comp.total_groups do
local c_val = m_data.captures[g]
table.insert(parts, c_val ~= nil and wrap_output(c_val, comp.is_bytes) or Runtime.builtins.None)
end
last_end = m_data.end_idx
n_splits = n_splits + 1
if m_data.end_idx == m_data.start_idx then
search_pos = m_data.end_idx + 1
else
search_pos = m_data.end_idx
end
end
end
table.insert(parts, wrap_output(usub(raw_s, last_end + 1), comp.is_bytes))
return { __type = "list", elements = parts }
end
},
sub = {
__type = "builtin_method", __name = "sub",
__call = function(args, kwargs)
local count = (args[4] ~= nil) and args[4] or (kwargs and kwargs.count) or 0
local res_s = subn_impl(args[1], args[2], args[3], count)
return res_s
end
},
subn = {
__type = "builtin_method", __name = "subn",
__call = function(args, kwargs)
local count = (args[4] ~= nil) and args[4] or (kwargs and kwargs.count) or 0
local res_s, n_subs = subn_impl(args[1], args[2], args[3], count)
return { __type = "tuple", elements = { res_s, n_subs } }
end
},
__repr__ = {
__type = "builtin_method",
__call = function(args)
local comp = args[1].__dict._compiled
return "re.compile(" .. RUtils.to_str(wrap_output(comp.raw_pattern, comp.is_bytes), true) .. ")"
end
}
}
pat_dict.__str__ = pat_dict.__repr__
PatternClass = PyClass.make_class("Pattern", { Runtime.builtins.object }, pat_dict)
PatternClass.__dict["__module__"] = "re"
end
local inst = PyClass.make_instance(PatternClass)
inst.__dict._compiled = compiled
inst.__dict.pattern = wrap_output(compiled.raw_pattern, compiled.is_bytes)
inst.__dict.flags = compiled.flags
inst.__dict.groups = compiled.total_groups
inst.__dict._route = compiled.route
local gi_pairs = {}
for k, v in pairs(compiled.group_index) do
table.insert(gi_pairs, { key = k, value = v })
end
inst.__dict.groupindex = { __type = "dict", pairs = gi_pairs }
return inst
end
-- ==========================================
-- 7. 頂層模組介面匯出 (re.*)
-- ==========================================
local function ensure_pattern_obj(pat_or_str, flags)
if type(pat_or_str) == "table" and pat_or_str.__type == "instance" and pat_or_str.__class == PatternClass then
if flags and flags ~= 0 then
Runtime.raise("ValueError", "cannot process flags argument with a compiled pattern")
end
return pat_or_str
end
local raw_pat, is_bytes = unpack_pattern_input(pat_or_str)
local f_num = (flags and not is_none(flags)) and RUtils.unwrap_num(flags) or 0
local compiled = compile_regex(raw_pat, f_num, is_bytes)
return make_pattern_object(compiled)
end
local re_mod_cache = nil
function Regexp.get_module()
if re_mod_cache then return re_mod_cache end
local err_cls = get_re_error_class()
-- 觸發初始化 MatchClass 與 PatternClass
make_pattern_object(compile_regex("", 0, false))
local re_dict = {
NOFLAG = FLAG_NOFLAG,
I = FLAG_IGNORECASE, IGNORECASE = FLAG_IGNORECASE,
L = FLAG_LOCALE, LOCALE = FLAG_LOCALE,
M = FLAG_MULTILINE, MULTILINE = FLAG_MULTILINE,
S = FLAG_DOTALL, DOTALL = FLAG_DOTALL,
U = FLAG_UNICODE, UNICODE = FLAG_UNICODE,
X = FLAG_VERBOSE, VERBOSE = FLAG_VERBOSE,
A = FLAG_ASCII, ASCII = FLAG_ASCII,
error = err_cls,
PatternError = err_cls,
Pattern = PatternClass,
Match = MatchClass,
compile = {
__type = "builtin_method", __name = "compile",
__call = function(args, kwargs)
local flags = (args and args[2]) or (kwargs and kwargs.flags) or 0
return ensure_pattern_obj(args and args[1], flags)
end
},
search = {
__type = "builtin_method", __name = "search",
__call = function(args, kwargs)
local flags = (args and args[3]) or (kwargs and kwargs.flags) or 0
local p = ensure_pattern_obj(args[1], flags)
return PatternClass.__dict.search.__call({ p, args[2] }, {})
end
},
match = {
__type = "builtin_method", __name = "match",
__call = function(args, kwargs)
local flags = (args and args[3]) or (kwargs and kwargs.flags) or 0
local p = ensure_pattern_obj(args[1], flags)
return PatternClass.__dict.match.__call({ p, args[2] }, {})
end
},
fullmatch = {
__type = "builtin_method", __name = "fullmatch",
__call = function(args, kwargs)
local flags = (args and args[3]) or (kwargs and kwargs.flags) or 0
local p = ensure_pattern_obj(args[1], flags)
return PatternClass.__dict.fullmatch.__call({ p, args[2] }, {})
end
},
findall = {
__type = "builtin_method", __name = "findall",
__call = function(args, kwargs)
local flags = (args and args[3]) or (kwargs and kwargs.flags) or 0
local p = ensure_pattern_obj(args[1], flags)
return PatternClass.__dict.findall.__call({ p, args[2] }, {})
end
},
finditer = {
__type = "builtin_method", __name = "finditer",
__call = function(args, kwargs)
local flags = (args and args[3]) or (kwargs and kwargs.flags) or 0
local p = ensure_pattern_obj(args[1], flags)
return PatternClass.__dict.finditer.__call({ p, args[2] }, {})
end
},
split = {
__type = "builtin_method", __name = "split",
__call = function(args, kwargs)
local maxsplit = (args and args[3]) or (kwargs and kwargs.maxsplit) or 0
local flags = (args and args[4]) or (kwargs and kwargs.flags) or 0
local p = ensure_pattern_obj(args[1], flags)
return PatternClass.__dict.split.__call({ p, args[2], maxsplit }, {})
end
},
sub = {
__type = "builtin_method", __name = "sub",
__call = function(args, kwargs)
local count = (args and args[4]) or (kwargs and kwargs.count) or 0
local flags = (args and args[5]) or (kwargs and kwargs.flags) or 0
local p = ensure_pattern_obj(args[1], flags)
return PatternClass.__dict.sub.__call({ p, args[2], args[3], count }, {})
end
},
subn = {
__type = "builtin_method", __name = "subn",
__call = function(args, kwargs)
local count = (args and args[4]) or (kwargs and kwargs.count) or 0
local flags = (args and args[5]) or (kwargs and kwargs.flags) or 0
local p = ensure_pattern_obj(args[1], flags)
return PatternClass.__dict.subn.__call({ p, args[2], args[3], count }, {})
end
},
escape = {
__type = "builtin_method", __name = "escape",
__call = function(args)
local s, is_b = unpack_pattern_input(args[1])
local escaped = s:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?%{%}%|\\#%s])", "\\%1")
return wrap_output(escaped, is_b)
end
},
purge = {
__type = "builtin_method", __name = "purge",
__call = function()
compile_cache = {}
return Runtime.builtins.None
end
}
}
re_mod_cache = require("Module:PythonScript/Library").make_namespace("re", re_dict)
return re_mod_cache
end
return Regexp