模組:Graph/Expression
外观
-- Module:Graph/Expression
-- 維基百科特化版:圖靈不完全之單行 JavaScript / Vega Expression 解析與求值引擎
local p = {}
local floor, ceil, abs, max, min = math.floor, math.ceil, math.abs, math.max, math.min
local sin, cos, tan, asin, acos, atan, atan2 = math.sin, math.cos, math.tan, math.asin, math.acos, math.atan, math.atan2
local sqrt, log, log10, exp, pow = math.sqrt, math.log, math.log10, math.exp, math.pow
local sub, find, match, gsub, byte, char, format = string.sub, string.find, string.match, string.gsub, string.byte, string.char, string.format
-- 特殊空值標記 (區分 JS 的 null 與 Lua 的 nil)
local NULL = setmetatable({}, { __tostring = function() return "null" end })
p.NULL = NULL
-- AST 快取池:同一頁面相同的公式只編譯一次
local astCache = {}
-- 嚴格封殺的越界關鍵字黑名單 (注意:Vega 內建函數 if(cond,a,b) 由語法樹特殊處理)
local FORBIDDEN_KEYWORDS = {
["else"] = true, ["for"] = true, ["while"] = true, ["do"] = true,
["switch"] = true, ["case"] = true, ["break"] = true, ["continue"] = true,
["return"] = true, ["function"] = true, ["var"] = true, ["let"] = true,
["const"] = true, ["new"] = true, ["delete"] = true, ["typeof"] = true,
["instanceof"] = true, ["void"] = true, ["yield"] = true, ["await"] = true,
["async"] = true, ["class"] = true, ["try"] = true, ["catch"] = true,
["finally"] = true, ["throw"] = true, ["with"] = true, ["import"] = true,
["export"] = true, ["eval"] = true, ["Function"] = true, ["window"] = true,
["document"] = true, ["globalThis"] = true, ["this"] = true,
["__proto__"] = true, ["prototype"] = true, ["constructor"] = true
}
-- ============================================================================
-- 階段一:詞法分析器 (Lexer / Tokenizer)
-- ============================================================================
function p.tokenize(str)
if type(str) ~= "string" then return nil, "Expression must be a string" end
local tokens = {}
local i, len = 1, #str
while i <= len do
local b = byte(str, i)
-- 1. 略過空白字元 (Space, Tab, CR, LF)
if b == 32 or b == 9 or b == 13 or b == 10 then
i = i + 1
-- 2. 封殺區塊大括號、分號與位元取反
elseif b == 123 or b == 125 or b == 59 or b == 126 then -- '{', '}', ';', '~'
return nil, "Forbidden syntax token: " .. char(b)
-- 3. 數值字面量 (整數、浮點數、科學記號、十六進位)
elseif (b >= 48 and b <= 57) or (b == 46 and i < len and byte(str, i + 1) >= 48 and byte(str, i + 1) <= 57) then
local s, e, numStr
if b == 48 and (byte(str, i + 1) == 120 or byte(str, i + 1) == 88) then
s, e = find(str, "^0[xX][%da-fA-F]+", i)
else
s, e = find(str, "^%d*%.?%d+[eE][%+%-]?%d+", i)
if not s then
s, e = find(str, "^%d+%.?%d*", i)
end
if not s then
s, e = find(str, "^%.%d+", i)
end
end
if not s then return nil, "Invalid number literal at position " .. i end
numStr = sub(str, s, e)
local val = tonumber(numStr)
if not val then return nil, "Malformed number: " .. numStr end
tokens[#tokens + 1] = { type = "NUM", val = val }
i = e + 1
-- 4. 字串字面量 ('...' 或 "...")
elseif b == 39 or b == 34 then -- ' or "
local quote = b
local buf = {}
i = i + 1
local closed = false
while i <= len do
local cb = byte(str, i)
if cb == quote then
closed = true
i = i + 1
break
elseif cb == 92 then -- '\' 跳脫字元
i = i + 1
if i > len then break end
local escB = byte(str, i)
if escB == 110 then buf[#buf + 1] = "\n"
elseif escB == 114 then buf[#buf + 1] = "\r"
elseif escB == 116 then buf[#buf + 1] = "\t"
elseif escB == 117 then -- \uXXXX
local hex = sub(str, i + 1, i + 4)
if match(hex, "^%x%x%x%x$") then
buf[#buf + 1] = mw.ustring.char(tonumber(hex, 16))
i = i + 4
else
buf[#buf + 1] = "u"
end
else
buf[#buf + 1] = char(escB)
end
i = i + 1
else
buf[#buf + 1] = char(cb)
i = i + 1
end
end
if not closed then return nil, "Unterminated string literal" end
tokens[#tokens + 1] = { type = "STR", val = table.concat(buf) }
-- 5. 識別碼與關鍵字 (Identifiers)
elseif (b >= 65 and b <= 90) or (b >= 97 and b <= 122) or b == 95 or b == 36 then
local s, e = find(str, "^[%a_$][%w_$]*", i)
local ident = sub(str, s, e)
if FORBIDDEN_KEYWORDS[ident] then
return nil, "Forbidden keyword in expression: " .. ident
end
tokens[#tokens + 1] = { type = "ID", val = ident }
i = e + 1
-- 6. 運算子與標點符號
else
local three = (i + 2 <= len) and sub(str, i, i + 2) or ""
local two = (i + 1 <= len) and sub(str, i, i + 1) or ""
local one = char(b)
-- 攔截遞增/遞減、複合賦值與單一賦值
if two == "++" or two == "--" or two == "+=" or two == "-=" or two == "*=" or two == "/=" or two == "%=" or two == "<<" or two == ">>" then
return nil, "Forbidden mutation/bitwise operator: " .. two
elseif three == "===" or three == "!==" then
tokens[#tokens + 1] = { type = "OP", val = three }
i = i + 3
elseif two == "==" or two == "!=" or two == "<=" or two == ">=" or two == "&&" or two == "||" or two == "**" or two == "=>" then
tokens[#tokens + 1] = { type = "OP", val = two }
i = i + 2
elseif one == "=" then
-- 單一 '=' 為賦值運算,嚴格禁止
return nil, "Forbidden assignment operator '='"
elseif one == "+" or one == "-" or one == "*" or one == "/" or one == "%" or one == "^" or one == "!" or one == "<" or one == ">" or one == "?" or one == ":" then
tokens[#tokens + 1] = { type = "OP", val = one }
i = i + 1
elseif one == "." or one == "," or one == "(" or one == ")" or one == "[" or one == "]" then
tokens[#tokens + 1] = { type = "PUNC", val = one }
i = i + 1
else
return nil, "Unexpected character in expression: " .. one
end
end
end
return tokens, nil
end
-- ============================================================================
-- 階段二:Pratt 剖析器與 AST 建構器 (Pratt Parser)
-- ============================================================================
local BP = {
["=>"] = 5,
["?"] = 10,
["||"] = 20,
["&&"] = 30,
["=="] = 40, ["!="] = 40, ["==="] = 40, ["!=="] = 40,
["<"] = 50, [">"] = 50, ["<="] = 50, [">="] = 50,
["+"] = 60, ["-"] = 60,
["*"] = 70, ["/"] = 70, ["%"] = 70,
["**"] = 80, ["^"] = 80,
["."] = 90, ["["] = 90, ["("] = 90
}
function p.parse(tokens)
local pos = 1
local nTokens = #tokens
local function peek() return tokens[pos] end
local function consume()
local t = tokens[pos]
pos = pos + 1
return t
end
local function expect(ttype, tval)
local t = tokens[pos]
if not t or t.type ~= ttype or (tval and t.val ~= tval) then
error("Syntax error: expected " .. (tval or ttype))
end
pos = pos + 1
return t
end
local parseExpr
-- 前綴處理 (nud: Null Denotation)
local function nud(tk)
local ttype, tval = tk.type, tk.val
if ttype == "NUM" or ttype == "STR" then
return { type = "Literal", value = tval }
elseif ttype == "ID" then
if tval == "true" then return { type = "Literal", value = true }
elseif tval == "false" then return { type = "Literal", value = false }
elseif tval == "null" then return { type = "Literal", value = NULL }
elseif tval == "undefined" then return { type = "Literal", value = nil }
elseif tval == "NaN" then return { type = "Literal", value = 0 / 0 }
elseif tval == "Infinity" then return { type = "Literal", value = math.huge }
elseif tval == "if" then
-- 支援 Vega 內建三元條件函數 if(test, consequent, alternate)
expect("PUNC", "(")
local test = parseExpr(0)
expect("PUNC", ",")
local consequent = parseExpr(0)
expect("PUNC", ",")
local alternate = parseExpr(0)
expect("PUNC", ")")
return { type = "Conditional", test = test, consequent = consequent, alternate = alternate }
end
return { type = "Identifier", name = tval }
elseif ttype == "OP" and (tval == "-" or tval == "+" or tval == "!") then
local operand = parseExpr(85)
return { type = "Unary", op = tval, expr = operand }
elseif ttype == "PUNC" and tval == "(" then
-- 檢查是否為空參數箭頭函數 () => ...
if peek() and peek().type == "PUNC" and peek().val == ")" then
consume()
return { type = "ParamList", params = {} }
end
local first = parseExpr(0)
-- 檢查是否為多參數序列 (a, b) => ...
if peek() and peek().type == "PUNC" and peek().val == "," then
local list = { first }
while peek() and peek().type == "PUNC" and peek().val == "," do
consume()
list[#list + 1] = parseExpr(0)
end
expect("PUNC", ")")
return { type = "Sequence", exprs = list }
end
expect("PUNC", ")")
return first
elseif ttype == "PUNC" and tval == "[" then
-- 陣列字面量 [a, b, c]
local elements = {}
if not (peek() and peek().type == "PUNC" and peek().val == "]") then
elements[#elements + 1] = parseExpr(0)
while peek() and peek().type == "PUNC" and peek().val == "," do
consume()
elements[#elements + 1] = parseExpr(0)
end
end
expect("PUNC", "]")
return { type = "Array", elements = elements }
end
error("Unexpected token in expression: " .. tostring(tval))
end
-- 中序與後綴處理 (led: Left Denotation)
local function led(left, tk)
local ttype, tval = tk.type, tk.val
-- 1. 屬性點取: obj.prop
if ttype == "PUNC" and tval == "." then
local propTk = expect("ID")
if FORBIDDEN_KEYWORDS[propTk.val] then
error("Forbidden property access: " .. propTk.val)
end
return {
type = "Member",
object = left,
property = { type = "Literal", value = propTk.val },
computed = false
}
-- 2. 中括號屬性存取: obj[expr]
elseif ttype == "PUNC" and tval == "[" then
local propExpr = parseExpr(0)
expect("PUNC", "]")
return {
type = "Member",
object = left,
property = propExpr,
computed = true
}
-- 3. 函數呼叫或 IIFE: callee(arg1, arg2, ...)
elseif ttype == "PUNC" and tval == "(" then
local args = {}
if not (peek() and peek().type == "PUNC" and peek().val == ")") then
args[#args + 1] = parseExpr(0)
while peek() and peek().type == "PUNC" and peek().val == "," do
consume()
args[#args + 1] = parseExpr(0)
end
end
expect("PUNC", ")")
return {
type = "Call",
callee = left,
args = args
}
-- 4. 三元條件運算子: test ? consequent : alternate (右結合)
elseif ttype == "OP" and tval == "?" then
local consequent = parseExpr(0)
expect("OP", ":")
local alternate = parseExpr(BP["?"] - 1)
return {
type = "Conditional",
test = left,
consequent = consequent,
alternate = alternate
}
-- 5. 箭頭函數 (Lambda): x => body 或 (x, y) => body (右結合)
elseif ttype == "OP" and tval == "=>" then
local params = {}
if left.type == "Identifier" then
params[1] = left.name
elseif left.type == "ParamList" then
params = left.params
elseif left.type == "Sequence" then
for i = 1, #left.exprs do
local item = left.exprs[i]
if item.type ~= "Identifier" then
error("Invalid arrow function parameter")
end
params[i] = item.name
end
else
error("Invalid arrow function parameter list")
end
local body = parseExpr(BP["=>"] - 1)
return {
type = "Arrow",
params = params,
body = body
}
-- 6. 次方運算 (右結合)
elseif ttype == "OP" and (tval == "**" or tval == "^") then
local right = parseExpr(BP[tval] - 1)
return { type = "Binary", op = "**", left = left, right = right }
-- 7. 一般二元運算 (左結合)
elseif ttype == "OP" and BP[tval] then
local right = parseExpr(BP[tval])
return { type = "Binary", op = tval, left = left, right = right }
end
error("Unexpected infix token: " .. tostring(tval))
end
parseExpr = function(minBP)
local tk = consume()
if not tk then error("Unexpected end of expression") end
local left = nud(tk)
while true do
local nextTk = peek()
if not nextTk then break end
local bp = BP[nextTk.val]
if not bp or bp <= minBP then break end
consume()
left = led(left, nextTk)
end
return left
end
local ast = parseExpr(0)
if pos <= nTokens then
error("Unexpected trailing token: " .. tostring(tokens[pos].val))
end
if ast.type == "Sequence" or ast.type == "ParamList" then
error("Invalid standalone parameter list")
end
return ast
end
-- 編譯字串為 AST (含快取與錯誤攔截)
function p.compile(exprStr)
if type(exprStr) ~= "string" then return nil, "Empty expression" end
exprStr = mw.text.trim(exprStr)
if exprStr == "" then return nil, "Empty expression" end
if astCache[exprStr] then
return astCache[exprStr], nil
end
local tokens, lexErr = p.tokenize(exprStr)
if not tokens then return nil, lexErr end
local ok, astOrErr = pcall(p.parse, tokens)
if not ok then
return nil, tostring(astOrErr)
end
astCache[exprStr] = astOrErr
return astOrErr, nil
end
-- ============================================================================
-- 階段三:全域沙盒環境與型別轉換輔助函數 (Global Sandbox Context)
-- ============================================================================
local function toJSNumber(v)
if type(v) == "number" then return v end
if v == nil then return 0 / 0 end -- Number(undefined) -> NaN
if v == NULL or v == false then return 0 end
if v == true then return 1 end
if type(v) == "string" then
local trimmed = mw.text.trim(v)
if trimmed == "" then return 0 end
return tonumber(trimmed) or (0 / 0)
end
return 0 / 0
end
local function toJSString(v)
if v == nil then return "undefined" end
if v == NULL then return "null" end
if type(v) == "boolean" then return v and "true" or "false" end
if type(v) == "number" then
if v ~= v then return "NaN" end
if v == math.huge then return "Infinity" end
if v == -math.huge then return "-Infinity" end
return tostring(v)
end
if type(v) == "string" then return v end
return tostring(v)
end
-- JS 真假值判斷 (0, "", false, null, undefined, NaN 皆為 false)
local function isJSTruthy(v)
if v == nil or v == false or v == NULL then return false end
if type(v) == "number" then
return v ~= 0 and v == v
end
if type(v) == "string" then
return #v > 0
end
return true
end
p.isTruthy = isJSTruthy
local function jsRound(x)
local n = toJSNumber(x)
if n ~= n or n == math.huge or n == -math.huge then return n end
return floor(n + 0.5)
end
local function jsParseFloat(v)
if type(v) == "number" then return v end
if type(v) ~= "string" then v = toJSString(v) end
local m = match(mw.text.trim(v), "^[%+%-]?%d*%.?%d+[eE][%+%-]?%d+")
or match(mw.text.trim(v), "^[%+%-]?%d+%.?%d*")
or match(mw.text.trim(v), "^[%+%-]?%.%d+")
return tonumber(m) or (0 / 0)
end
local function jsParseInt(v, radix)
if type(v) == "number" then
if v ~= v or abs(v) == math.huge then return 0 / 0 end
return v >= 0 and floor(v) or ceil(v)
end
if type(v) ~= "string" then v = toJSString(v) end
local s = mw.text.trim(v)
local r = tonumber(radix)
if not r then
if match(s, "^[%+%-]?0[xX]") then r = 16 else r = 10 end
end
if r == 10 then
local m = match(s, "^[%+%-]?%d+")
return tonumber(m) or (0 / 0)
end
return tonumber(s, r) or (0 / 0)
end
local function parseISOComponents(v)
if type(v) == "number" then
local t = os.date("!*t", floor(v / 1000))
if t then return t.year, t.month, t.day, t.hour, t.min, t.sec end
end
local s = tostring(v or "")
local y, m, d, hr, mn, sc = match(s, "^(%d%d%d%d)[%-%/](%d%d?)[%-%/](%d%d?)[T%s](%d%d?):(%d%d?):?(%d*)")
if y then
return tonumber(y), tonumber(m), tonumber(d), tonumber(hr) or 0, tonumber(mn) or 0, tonumber(sc) or 0
end
y, m, d = match(s, "^(%d%d%d%d)[%-%/](%d%d?)[%-%/](%d%d?)")
if y then return tonumber(y), tonumber(m), tonumber(d), 0, 0, 0 end
y, m = match(s, "^(%d%d%d%d)[%-%/](%d%d?)$")
if y then return tonumber(y), tonumber(m), 1, 0, 0, 0 end
y = match(s, "^(%d%d%d%d)$")
if y then return tonumber(y), 1, 1, 0, 0, 0 end
return nil
end
local MATH_LIB = {
PI = math.pi,
E = exp(1),
LN2 = log(2),
LN10 = log(10),
LOG2E = 1 / log(2),
LOG10E = log10(exp(1)),
SQRT1_2 = sqrt(0.5),
SQRT2 = sqrt(2),
abs = function(x) return abs(toJSNumber(x)) end,
acos = function(x) return acos(toJSNumber(x)) end,
asin = function(x) return asin(toJSNumber(x)) end,
atan = function(x) return atan(toJSNumber(x)) end,
atan2 = function(y, x) return atan2(toJSNumber(y), toJSNumber(x)) end,
cbrt = function(x)
local n = toJSNumber(x)
return n < 0 and -((-n) ^ (1 / 3)) or (n ^ (1 / 3))
end,
ceil = function(x) return ceil(toJSNumber(x)) end,
cos = function(x) return cos(toJSNumber(x)) end,
cosh = function(x) return math.cosh and math.cosh(toJSNumber(x)) or ((exp(toJSNumber(x)) + exp(-toJSNumber(x))) / 2) end,
exp = function(x) return exp(toJSNumber(x)) end,
expm1 = function(x) return exp(toJSNumber(x)) - 1 end,
floor = function(x) return floor(toJSNumber(x)) end,
hypot = function(x, y)
local nx, ny = toJSNumber(x), toJSNumber(y or 0)
return sqrt(nx * nx + ny * ny)
end,
log = function(x) return log(toJSNumber(x)) end,
log10 = function(x) return log10(toJSNumber(x)) end,
log2 = function(x) return log(toJSNumber(x)) / log(2) end,
log1p = function(x) return log(1 + toJSNumber(x)) end,
max = function(...)
local args = { ... }
if #args == 0 then return -math.huge end
local m = toJSNumber(args[1])
for i = 2, #args do
local v = toJSNumber(args[i])
if v ~= v then return 0 / 0 end
if v > m then m = v end
end
return m
end,
min = function(...)
local args = { ... }
if #args == 0 then return math.huge end
local m = toJSNumber(args[1])
for i = 2, #args do
local v = toJSNumber(args[i])
if v ~= v then return 0 / 0 end
if v < m then m = v end
end
return m
end,
pow = function(x, y) return pow(toJSNumber(x), toJSNumber(y)) end,
random = function() return math.random() end,
round = jsRound,
sign = function(x)
local n = toJSNumber(x)
if n ~= n or n == 0 then return n end
return n > 0 and 1 or -1
end,
sin = function(x) return sin(toJSNumber(x)) end,
sinh = function(x) return math.sinh and math.sinh(toJSNumber(x)) or ((exp(toJSNumber(x)) - exp(-toJSNumber(x))) / 2) end,
sqrt = function(x) return sqrt(toJSNumber(x)) end,
tan = function(x) return tan(toJSNumber(x)) end,
tanh = function(x)
local n = toJSNumber(x)
if math.tanh then return math.tanh(n) end
local e2x = exp(2 * n)
return (e2x - 1) / (e2x + 1)
end,
trunc = function(x)
local n = toJSNumber(x)
if n ~= n or abs(n) == math.huge then return n end
return n >= 0 and floor(n) or ceil(n)
end
}
-- 建構全域沙盒 (同時將 Math.* 展開至全域以相容 Vega 省略 Math. 的寫法)
local GLOBAL_ENV = {
Math = MATH_LIB,
parseFloat = jsParseFloat,
parseInt = jsParseInt,
Number = toJSNumber,
String = toJSString,
Boolean = function(v) return isJSTruthy(v) end,
isNaN = function(v)
local n = toJSNumber(v)
return n ~= n
end,
isFinite = function(v)
local n = toJSNumber(v)
return n == n and abs(n) ~= math.huge
end,
isValid = function(v)
return v ~= nil and v ~= NULL and v == v
end,
clamp = function(val, lo, hi)
return max(toJSNumber(lo), min(toJSNumber(hi), toJSNumber(val)))
end,
length = function(v)
if type(v) == "string" then return mw.ustring.len(v) end
if type(v) == "table" then return #v end
return 0
end,
lower = function(s) return mw.ustring.lower(toJSString(s)) end,
upper = function(s) return mw.ustring.upper(toJSString(s)) end,
trim = function(s) return mw.text.trim(toJSString(s)) end,
slice = function(v, sIdx, eIdx)
if type(v) == "string" then
local len = mw.ustring.len(v)
local s = tonumber(sIdx) or 0
local e = (eIdx ~= nil) and tonumber(eIdx) or len
if s < 0 then s = max(0, len + s) end
if e < 0 then e = max(0, len + e) end
if e <= s then return "" end
return mw.ustring.sub(v, s + 1, e)
elseif type(v) == "table" then
local len = #v
local s = tonumber(sIdx) or 0
local e = (eIdx ~= nil) and tonumber(eIdx) or len
if s < 0 then s = max(0, len + s) end
if e < 0 then e = max(0, len + e) end
local out = {}
for idx = s + 1, min(len, e) do out[#out + 1] = v[idx] end
return out
end
return ""
end,
substring = function(s, sIdx, eIdx)
local str = toJSString(s)
local len = mw.ustring.len(str)
local a = max(0, min(len, tonumber(sIdx) or 0))
local b = (eIdx ~= nil) and max(0, min(len, tonumber(eIdx) or len)) or len
if a > b then a, b = b, a end
return mw.ustring.sub(str, a + 1, b)
end,
indexof = function(v, search)
if type(v) == "string" then
local idx = mw.ustring.find(v, toJSString(search), 1, true)
return idx and (idx - 1) or -1
elseif type(v) == "table" then
for i = 1, #v do
if v[i] == search then return i - 1 end
end
end
return -1
end,
replace = function(s, pat, repl)
local str = toJSString(s)
local pStr = toJSString(pat)
local rStr = toJSString(repl)
local escapedPat = gsub(pStr, "([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1")
local res = gsub(str, escapedPat, rStr)
return res
end,
split = function(s, sep)
return mw.text.split(toJSString(s), toJSString(sep or ","), true)
end,
join = function(arr, sep)
if type(arr) ~= "table" then return toJSString(arr) end
return table.concat(arr, toJSString(sep or ","))
end,
year = function(d)
local y = parseISOComponents(d)
return y or (0 / 0)
end,
month = function(d)
local _, m = parseISOComponents(d)
return m and (m - 1) or (0 / 0) -- JS month 為 0..11
end,
date = function(d)
local _, _, day = parseISOComponents(d)
return day or (0 / 0)
end,
hours = function(d)
local _, _, _, hr = parseISOComponents(d)
return hr or (0 / 0)
end,
minutes = function(d)
local _, _, _, _, mn = parseISOComponents(d)
return mn or (0 / 0)
end,
seconds = function(d)
local _, _, _, _, _, sc = parseISOComponents(d)
return sc or (0 / 0)
end,
datetime = function(y, m, d, hr, mn, sc)
if m == nil and type(y) == "string" then
return mw.text.trim(y)
end
local ny = tonumber(y) or 1970
local nm = (tonumber(m) or 0) + 1
local nd = tonumber(d) or 1
if hr then
return format("%04d-%02d-%02dT%02d:%02d:%02d", ny, nm, nd, tonumber(hr) or 0, tonumber(mn) or 0, tonumber(sc) or 0)
end
return format("%04d-%02d-%02d", ny, nm, nd)
end,
utc = function(y, m, d, hr, mn, sc)
local ny = tonumber(y) or 1970
local nm = (tonumber(m) or 0) + 1
local nd = tonumber(d) or 1
local ok, ts = pcall(os.time, { year = ny, month = nm, day = nd, hour = tonumber(hr) or 0, min = tonumber(mn) or 0, sec = tonumber(sc) or 0 })
if ok and ts then return ts * 1000 end
return ((ny - 1970) * 365.25 + (nm - 1) * 30.4375 + (nd - 1)) * 86400000
end,
now = function()
return os.time() * 1000
end,
time = function(d)
local y, m, day, hr, mn, sc = parseISOComponents(d)
if not y then return tonumber(d) or (0 / 0) end
local ok, ts = pcall(os.time, { year = y, month = m, day = day, hour = hr, min = mn, sec = sc })
if ok and ts then return ts * 1000 end
return ((y - 1970) * 365.25 + (m - 1) * 30.4375 + (day - 1)) * 86400000
end
}
for k, v in pairs(MATH_LIB) do
GLOBAL_ENV[k] = v
end
-- 支援字串與數值的原生 JS 方法 (如 str.slice(), str.toLowerCase(), num.toFixed())
local function getPrimitiveMember(obj, prop)
if type(obj) == "string" then
if prop == "length" then return mw.ustring.len(obj)
elseif prop == "toLowerCase" then return function() return mw.ustring.lower(obj) end
elseif prop == "toUpperCase" then return function() return mw.ustring.upper(obj) end
elseif prop == "trim" then return function() return mw.text.trim(obj) end
elseif prop == "slice" then return function(s, e) return GLOBAL_ENV.slice(obj, s, e) end
elseif prop == "substring" then return function(s, e) return GLOBAL_ENV.substring(obj, s, e) end
elseif prop == "substr" then
return function(s, len)
local startIdx = tonumber(s) or 0
local l = tonumber(len) or mw.ustring.len(obj)
return GLOBAL_ENV.slice(obj, startIdx, startIdx + l)
end
elseif prop == "indexOf" then return function(subStr) return GLOBAL_ENV.indexof(obj, subStr) end
elseif prop == "replace" then return function(pat, repl) return GLOBAL_ENV.replace(obj, pat, repl) end
elseif prop == "split" then return function(sep) return GLOBAL_ENV.split(obj, sep) end
elseif tonumber(prop) then
local idx = tonumber(prop) + 1
if idx >= 1 and idx <= mw.ustring.len(obj) then
return mw.ustring.sub(obj, idx, idx)
end
end
elseif type(obj) == "number" then
if prop == "toFixed" then
return function(digits)
local d = max(0, min(20, floor(tonumber(digits) or 0)))
return format("%." .. d .. "f", obj)
end
elseif prop == "toString" then
return function() return toJSString(obj) end
end
end
return nil
end
-- ============================================================================
-- 階段四:AST 執行器與隱式轉型 (Evaluator)
-- ============================================================================
function p.evalAST(ast, context)
context = context or {}
local steps = 0
local maxSteps = 2500
local function eval(node, scope, depth)
steps = steps + 1
if steps > maxSteps then error("Expression exceeded maximum evaluation steps") end
if depth > 16 then error("Expression exceeded maximum call depth") end
if not node then return nil end
local ntype = node.type
-- 1. 常數
if ntype == "Literal" then
return node.value
-- 2. 陣列字面量
elseif ntype == "Array" then
local arr = {}
for i = 1, #node.elements do
arr[i] = eval(node.elements[i], scope, depth + 1)
end
return arr
-- 3. 識別碼查找:區域變數 (Lambda) -> 外部 context (datum, signals) -> datum 欄位備援 -> GLOBAL_ENV
elseif ntype == "Identifier" then
local name = node.name
if scope and scope[name] ~= nil then return scope[name] end
if context[name] ~= nil then return context[name] end
if type(context.signals) == "table" and context.signals[name] ~= nil then
return context.signals[name]
end
if GLOBAL_ENV[name] ~= nil then return GLOBAL_ENV[name] end
-- 容錯:若部分公式省略 datum. 直接寫欄位名
if type(context.datum) == "table" and context.datum[name] ~= nil then
return context.datum[name]
end
return nil
-- 4. 單目運算
elseif ntype == "Unary" then
local val = eval(node.expr, scope, depth + 1)
local op = node.op
if op == "-" then return -toJSNumber(val)
elseif op == "+" then return toJSNumber(val)
elseif op == "!" then return not isJSTruthy(val)
end
-- 5. 三元條件運算 (Short-circuit)
elseif ntype == "Conditional" then
local testVal = eval(node.test, scope, depth + 1)
if isJSTruthy(testVal) then
return eval(node.consequent, scope, depth + 1)
else
return eval(node.alternate, scope, depth + 1)
end
-- 6. 二元運算 (含短路邏輯與 '+' 字串隱式轉型)
elseif ntype == "Binary" then
local op = node.op
if op == "&&" then
local lval = eval(node.left, scope, depth + 1)
if not isJSTruthy(lval) then return lval end
return eval(node.right, scope, depth + 1)
elseif op == "||" then
local lval = eval(node.left, scope, depth + 1)
if isJSTruthy(lval) then return lval end
return eval(node.right, scope, depth + 1)
end
local lval = eval(node.left, scope, depth + 1)
local rval = eval(node.right, scope, depth + 1)
if op == "+" then
-- JS 規則:任一側為字串,則轉為字串串接 (例如 '' + dateStamp)
if type(lval) == "string" or type(rval) == "string" then
return toJSString(lval) .. toJSString(rval)
end
return toJSNumber(lval) + toJSNumber(rval)
elseif op == "-" then return toJSNumber(lval) - toJSNumber(rval)
elseif op == "*" then return toJSNumber(lval) * toJSNumber(rval)
elseif op == "/" then return toJSNumber(lval) / toJSNumber(rval)
elseif op == "%" then
local ln, rn = toJSNumber(lval), toJSNumber(rval)
if rn == 0 then return 0 / 0 end
return ln - floor(ln / rn) * rn * (ln * rn < 0 and -1 or 1)
elseif op == "**" then return pow(toJSNumber(lval), toJSNumber(rval))
elseif op == "<" then
if type(lval) == "string" and type(rval) == "string" then return lval < rval end
return toJSNumber(lval) < toJSNumber(rval)
elseif op == ">" then
if type(lval) == "string" and type(rval) == "string" then return lval > rval end
return toJSNumber(lval) > toJSNumber(rval)
elseif op == "<=" then
if type(lval) == "string" and type(rval) == "string" then return lval <= rval end
return toJSNumber(lval) <= toJSNumber(rval)
elseif op == ">=" then
if type(lval) == "string" and type(rval) == "string" then return lval >= rval end
return toJSNumber(lval) >= toJSNumber(rval)
elseif op == "===" then
return lval == rval
elseif op == "!==" then
return lval ~= rval
elseif op == "==" then
if type(lval) == type(rval) then return lval == rval end
if (lval == nil or lval == NULL) and (rval == nil or rval == NULL) then return true end
if (type(lval) == "number" and type(rval) == "string") or (type(lval) == "string" and type(rval) == "number") then
return toJSNumber(lval) == toJSNumber(rval)
end
return lval == rval
elseif op == "!=" then
if type(lval) == type(rval) then return lval ~= rval end
if (lval == nil or lval == NULL) and (rval == nil or rval == NULL) then return false end
if (type(lval) == "number" and type(rval) == "string") or (type(lval) == "string" and type(rval) == "number") then
return toJSNumber(lval) ~= toJSNumber(rval)
end
return lval ~= rval
end
-- 7. 屬性成員存取 (datum.pop.Population 或 datum[''+dateStamp] 或 arr[0])
elseif ntype == "Member" then
local obj = eval(node.object, scope, depth + 1)
if obj == nil or obj == NULL then
error("Cannot read property of undefined/null")
end
local prop = eval(node.property, scope, depth + 1)
local propStr = toJSString(prop)
if FORBIDDEN_KEYWORDS[propStr] then
error("Forbidden property access: " .. propStr)
end
if type(obj) == "table" then
if propStr == "length" then return #obj end
if obj[prop] ~= nil then return obj[prop] end
if obj[propStr] ~= nil then return obj[propStr] end
-- JS 陣列為 0-based,若為整數索引且 0-based 查無鍵,自動相容 Lua 1-based 陣列
local numIdx = tonumber(prop)
if numIdx and obj[numIdx] == nil and obj[numIdx + 1] ~= nil then
return obj[numIdx + 1]
end
return nil
else
return getPrimitiveMember(obj, propStr)
end
-- 8. 箭頭函數建立 (Closure)
elseif ntype == "Arrow" then
return {
__is_arrow = true,
params = node.params,
body = node.body,
scope = scope
}
-- 9. 函數或 Lambda 立即呼叫 (Call / IIFE)
elseif ntype == "Call" then
local fn = eval(node.callee, scope, depth + 1)
local argVals = {}
for i = 1, #node.args do
argVals[i] = eval(node.args[i], scope, depth + 1)
end
if type(fn) == "function" then
return fn(unpack(argVals, 1, #node.args))
elseif type(fn) == "table" and fn.__is_arrow then
local childScope = {}
if fn.scope then
for k, v in pairs(fn.scope) do childScope[k] = v end
end
for i = 1, #fn.params do
local val = argVals[i]
if val == nil then val = NULL end
childScope[fn.params[i]] = val
end
return eval(fn.body, childScope, depth + 1)
else
error("Target is not a callable function")
end
end
error("Unknown AST node type: " .. tostring(ntype))
end
local res = eval(ast, nil, 0)
if res == NULL then return nil end
return res
end
-- 單次直接編譯並求值對外介面:p.evaluate(exprStr, context)
function p.evaluate(exprStr, context)
local ast, err = p.compile(exprStr)
if not ast then return false, err end
local ok, res = pcall(p.evalAST, ast, context)
if not ok then return false, tostring(res) end
return true, res
end
-- ============================================================================
-- 批次執行 Vega Data Transforms (支援 formula, filter, lookup)
-- ============================================================================
function p.applyTransforms(records, transforms, datasets, signals)
if type(records) ~= "table" or type(transforms) ~= "table" or #transforms == 0 then
return records, false
end
-- 將唯讀代理表複製為可寫入的標準 Lua Table 陣列
local rows = {}
local rIdx = 1
while records[rIdx] ~= nil do
local src = records[rIdx]
if type(src) == "table" then
local copy = {}
for k, v in pairs(src) do copy[k] = v end
rows[#rows + 1] = copy
end
rIdx = rIdx + 1
end
local ctx = { datum = nil, signals = signals or {} }
local function resolveNumParam(pVal)
if type(pVal) == "number" then return pVal end
if type(pVal) == "table" then
if type(pVal.signal) == "string" and ctx.signals[pVal.signal] ~= nil then
return tonumber(ctx.signals[pVal.signal])
elseif type(pVal.expr) == "string" then
local okE, v = p.evaluate(pVal.expr, ctx)
if okE then return tonumber(v) end
end
elseif type(pVal) == "string" then
local num = tonumber(pVal)
if num then return num end
local okE, v = p.evaluate(pVal, ctx)
if okE then return tonumber(v) end
end
return nil
end
for _, tf in ipairs(transforms) do
local tfType = tf.type
local targetField = tf.field or tf.as
-- 0. sequence 轉換: { "type": "sequence", "start": 0, "stop": 6.3, "step": 0.1, "as": "x" }
if tfType == "sequence" then
local startVal = resolveNumParam(tf.start)
local stopVal = resolveNumParam(tf.stop)
if startVal and stopVal then
local stepVal = resolveNumParam(tf.step) or ((stopVal < startVal) and -1 or 1)
local outField = (type(targetField) == "string" and targetField ~= "") and targetField or "data"
local seqRows = {}
if stepVal ~= 0 and ((stepVal > 0 and startVal < stopVal) or (stepVal < 0 and startVal > stopVal)) then
local k = 0
local maxSeq = 5000
while k < maxSeq do
local val = startVal + k * stepVal
if (stepVal > 0 and val >= stopVal - 1e-9) or (stepVal < 0 and val <= stopVal + 1e-9) then
break
end
if abs(val) < 1e-12 then val = 0 end
val = tonumber(format("%.12g", val)) or val
seqRows[#seqRows + 1] = { [outField] = val }
k = k + 1
end
end
rows = seqRows
end
-- 1. formula 轉換: { "type": "formula", "field"/"as": "v", "expr": "..." }
elseif tfType == "formula" and type(targetField) == "string" and type(tf.expr) == "string" then
local ast, err = p.compile(tf.expr)
if not ast then
-- 語法越界或非法 JS,立即通知外層觸發 Fallback
return nil, true, err
end
for i = 1, #rows do
ctx.datum = rows[i]
local ok, val = pcall(p.evalAST, ast, ctx)
if ok then
rows[i][targetField] = val
else
rows[i][targetField] = nil
end
end
-- 2. filter 轉換: { "type": "filter", "test": "..." } (或 "expr")
elseif tfType == "filter" then
local exprStr = tf.test or tf.expr
if type(exprStr) == "string" then
local ast, err = p.compile(exprStr)
if not ast then
return nil, true, err
end
local filtered = {}
for i = 1, #rows do
ctx.datum = rows[i]
local ok, keep = pcall(p.evalAST, ast, ctx)
if ok and isJSTruthy(keep) then
filtered[#filtered + 1] = rows[i]
end
end
rows = filtered
end
-- 3. lookup 轉換 (關聯外部資料表,如 datum.pop.Population)
elseif tfType == "lookup" and datasets and tf.on and tf.keys and tf.as then
local targetSet = datasets[tf.on]
local onKey = tf.onKey
local keyField = type(tf.keys) == "table" and tf.keys[1] or tf.keys
local asField = type(tf.as) == "table" and tf.as[1] or tf.as
if type(targetSet) == "table" and keyField and asField then
local indexMap = {}
local tIdx = 1
while targetSet[tIdx] ~= nil do
local item = targetSet[tIdx]
if type(item) == "table" then
local kv = onKey and item[onKey] or item.id
if kv ~= nil then indexMap[tostring(kv)] = item end
end
tIdx = tIdx + 1
end
for i = 1, #rows do
local fk = rows[i][keyField]
if fk ~= nil then
rows[i][asField] = indexMap[tostring(fk)] or tf.default
end
end
end
-- 4. fold 轉換 (將多欄寬表展開為 key / value 長表,支援空白與底線欄位名自動對齊)
elseif tfType == "fold" and tf.fields then
local fieldList = type(tf.fields) == "table" and tf.fields or { tf.fields }
local asKey = (type(tf.as) == "table" and tf.as[1]) or "key"
local asVal = (type(tf.as) == "table" and tf.as[2]) or "value"
local folded = {}
for i = 1, #rows do
local srcRow = rows[i]
for fIdx = 1, #fieldList do
local fName = tostring(fieldList[fIdx])
local val = srcRow[fName]
if val == nil then
local underName = gsub(fName, " ", "_")
val = srcRow[underName]
if val == nil then
local spaceName = gsub(fName, "_", " ")
val = srcRow[spaceName]
end
end
local newRow = {}
for k, v in pairs(srcRow) do newRow[k] = v end
newRow[asKey] = fName
newRow[asVal] = val
folded[#folded + 1] = newRow
end
end
rows = folded
-- 5. sort 轉換 (依指定欄位排序資料列,嚴格遵守 Lua table.sort 弱序規範)
elseif tfType == "sort" and tf.by then
local byRaw = type(tf.by) == "table" and tf.by[1] or tf.by
if type(byRaw) == "string" and byRaw ~= "" then
local desc = (sub(byRaw, 1, 1) == "-")
local sortField = desc and sub(byRaw, 2) or byRaw
table.sort(rows, function(a, b)
if type(a) ~= "table" or type(b) ~= "table" then return false end
local va, vb = a[sortField], b[sortField]
if va == vb then return false end
if va == nil or va == NULL then return false end
if vb == nil or vb == NULL then return true end
local na, nb = tonumber(va), tonumber(vb)
if na and nb then
if na == nb then return false end
if desc then return na > nb else return na < nb end
end
local sa, sb = tostring(va), tostring(vb)
if sa == sb then return false end
if desc then return sa > sb else return sa < sb end
end)
end
-- 6. aggregate 轉換 (支援 groupby 與 sum / min / max / mean / count 彙總,如歷史溫度與全球總計)
elseif tfType == "aggregate" and type(tf.summarize) == "table" then
local groupFields = {}
if type(tf.groupby) == "table" then
for _, gf in ipairs(tf.groupby) do groupFields[#groupFields + 1] = tostring(gf) end
elseif type(tf.groupby) == "string" then
groupFields[1] = tf.groupby
end
local groups, groupOrder = {}, {}
for i = 1, #rows do
local r = rows[i]
local keyParts = {}
for g = 1, #groupFields do
keyParts[g] = tostring(r[groupFields[g]] or "")
end
local gKey = table.concat(keyParts, "\0")
local grp = groups[gKey]
if not grp then
grp = { keyRow = {}, items = {} }
for g = 1, #groupFields do
local gf = groupFields[g]
grp.keyRow[gf] = r[gf]
end
groups[gKey] = grp
groupOrder[#groupOrder + 1] = grp
end
grp.items[#grp.items + 1] = r
end
local aggRows = {}
for _, grp in ipairs(groupOrder) do
local outRow = {}
for k, v in pairs(grp.keyRow) do outRow[k] = v end
for _, sumSpec in ipairs(tf.summarize) do
local fld = sumSpec.field
local ops = type(sumSpec.ops) == "table" and sumSpec.ops or { sumSpec.ops }
local asList = type(sumSpec.as) == "table" and sumSpec.as or { sumSpec.as }
for opIdx, op in ipairs(ops) do
local outName = asList[opIdx] or ( tostring(op) .. "_" .. tostring(fld) )
local sumVal, minVal, maxVal, cnt = 0, math.huge, -math.huge, 0
for _, item in ipairs(grp.items) do
local v = fld and tonumber(item[fld]) or nil
if v then
sumVal = sumVal + v
if v < minVal then minVal = v end
if v > maxVal then maxVal = v end
cnt = cnt + 1
end
end
if op == "sum" then outRow[outName] = sumVal
elseif op == "min" then outRow[outName] = (cnt > 0) and minVal or nil
elseif op == "max" then outRow[outName] = (cnt > 0) and maxVal or nil
elseif op == "mean" or op == "average" then outRow[outName] = (cnt > 0) and (sumVal / cnt) or nil
elseif op == "count" then outRow[outName] = #grp.items end
end
end
aggRows[#aggRows + 1] = outRow
end
rows = aggRows
-- 7. sample 轉換 (採用 O(N) 蓄水池抽樣演算法隨機抽取 size 筆資料,並保持原始資料相對順序)
elseif tfType == "sample" then
local maxSize = floor(resolveNumParam(tf.size) or 1000)
if maxSize > 0 and #rows > maxSize then
local indices = {}
for i = 1, maxSize do
indices[i] = i
end
for i = maxSize + 1, #rows do
local j = math.random(1, i)
if j <= maxSize then
indices[j] = i
end
end
table.sort(indices)
local sampled = {}
for i = 1, maxSize do
sampled[i] = rows[indices[i]]
end
rows = sampled
end
end
end
return rows, false
end
return p