跳转到内容

模組: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