410 lines
11 KiB
Lua
410 lines
11 KiB
Lua
--- Utility module for parsing and evaluating CSS-like calc() expressions
|
|
--- Supports arithmetic operations (+, -, *, /) with mixed units (px, %, vw, vh, ew, eh)
|
|
---@class Calc
|
|
local Calc = {}
|
|
|
|
--- Initialize Calc module with dependencies
|
|
---@param deps table Dependencies: { ErrorHandler = table? }
|
|
function Calc.init(deps)
|
|
Calc._ErrorHandler = deps.ErrorHandler
|
|
end
|
|
|
|
--- Token types for lexical analysis
|
|
local TokenType = {
|
|
NUMBER = "NUMBER",
|
|
UNIT = "UNIT",
|
|
PLUS = "PLUS",
|
|
MINUS = "MINUS",
|
|
MULTIPLY = "MULTIPLY",
|
|
DIVIDE = "DIVIDE",
|
|
LPAREN = "LPAREN",
|
|
RPAREN = "RPAREN",
|
|
EOF = "EOF",
|
|
}
|
|
|
|
--- Tokenize a calc expression string into tokens
|
|
---@param expr string The expression to tokenize (e.g., "50% - 10vw")
|
|
---@return table|nil tokens Array of tokens with type, value, unit
|
|
---@return string? error Error message if tokenization fails
|
|
local function tokenize(expr)
|
|
local tokens = {}
|
|
local i = 1
|
|
local len = #expr
|
|
|
|
while i <= len do
|
|
local char = expr:sub(i, i)
|
|
|
|
-- Skip whitespace
|
|
if char:match("%s") then
|
|
i = i + 1
|
|
-- Number (including decimals, but NOT negative - handled separately below)
|
|
elseif char:match("%d") or (char == "." and expr:sub(i + 1, i + 1):match("%d")) then
|
|
local numStr = ""
|
|
|
|
-- Parse integer and decimal parts
|
|
while i <= len and (expr:sub(i, i):match("%d") or expr:sub(i, i) == ".") do
|
|
numStr = numStr .. expr:sub(i, i)
|
|
i = i + 1
|
|
end
|
|
|
|
local num = tonumber(numStr)
|
|
if not num then
|
|
return nil, "Invalid number: " .. numStr
|
|
end
|
|
|
|
-- Check for unit following the number
|
|
local unitStr = ""
|
|
while i <= len and expr:sub(i, i):match("[%a%%]") do
|
|
unitStr = unitStr .. expr:sub(i, i)
|
|
i = i + 1
|
|
end
|
|
|
|
-- Default to px if no unit
|
|
if unitStr == "" then
|
|
unitStr = "px"
|
|
end
|
|
|
|
-- Validate unit
|
|
local validUnits = { px = true, ["%"] = true, vw = true, vh = true, ew = true, eh = true }
|
|
if not validUnits[unitStr] then
|
|
return nil, "Invalid unit: " .. unitStr
|
|
end
|
|
|
|
table.insert(tokens, {
|
|
type = TokenType.NUMBER,
|
|
value = num,
|
|
unit = unitStr,
|
|
})
|
|
-- Operators
|
|
elseif char == "+" then
|
|
table.insert(tokens, { type = TokenType.PLUS })
|
|
i = i + 1
|
|
elseif char == "-" then
|
|
-- Check if this is a negative number or subtraction
|
|
-- It's a negative number if previous token is an operator or opening paren
|
|
local prevToken = tokens[#tokens]
|
|
if
|
|
not prevToken
|
|
or prevToken.type == TokenType.PLUS
|
|
or prevToken.type == TokenType.MINUS
|
|
or prevToken.type == TokenType.MULTIPLY
|
|
or prevToken.type == TokenType.DIVIDE
|
|
or prevToken.type == TokenType.LPAREN
|
|
then
|
|
-- This is a negative number, continue to number parsing
|
|
local numStr = "-"
|
|
i = i + 1
|
|
|
|
-- Parse integer and decimal parts
|
|
while i <= len and (expr:sub(i, i):match("%d") or expr:sub(i, i) == ".") do
|
|
numStr = numStr .. expr:sub(i, i)
|
|
i = i + 1
|
|
end
|
|
|
|
local num = tonumber(numStr)
|
|
if not num then
|
|
return nil, "Invalid number: " .. numStr
|
|
end
|
|
|
|
-- Check for unit following the number
|
|
local unitStr = ""
|
|
while i <= len and expr:sub(i, i):match("[%a%%]") do
|
|
unitStr = unitStr .. expr:sub(i, i)
|
|
i = i + 1
|
|
end
|
|
|
|
-- Default to px if no unit
|
|
if unitStr == "" then
|
|
unitStr = "px"
|
|
end
|
|
|
|
-- Validate unit
|
|
local validUnits = { px = true, ["%"] = true, vw = true, vh = true, ew = true, eh = true }
|
|
if not validUnits[unitStr] then
|
|
return nil, "Invalid unit: " .. unitStr
|
|
end
|
|
|
|
table.insert(tokens, {
|
|
type = TokenType.NUMBER,
|
|
value = num,
|
|
unit = unitStr,
|
|
})
|
|
else
|
|
-- This is subtraction operator
|
|
table.insert(tokens, { type = TokenType.MINUS })
|
|
i = i + 1
|
|
end
|
|
elseif char == "*" then
|
|
table.insert(tokens, { type = TokenType.MULTIPLY })
|
|
i = i + 1
|
|
elseif char == "/" then
|
|
table.insert(tokens, { type = TokenType.DIVIDE })
|
|
i = i + 1
|
|
elseif char == "(" then
|
|
table.insert(tokens, { type = TokenType.LPAREN })
|
|
i = i + 1
|
|
elseif char == ")" then
|
|
table.insert(tokens, { type = TokenType.RPAREN })
|
|
i = i + 1
|
|
else
|
|
return nil, "Unexpected character: " .. char
|
|
end
|
|
end
|
|
|
|
table.insert(tokens, { type = TokenType.EOF })
|
|
return tokens
|
|
end
|
|
|
|
--- Parser for calc expressions using recursive descent
|
|
---@class Parser
|
|
---@field tokens table Array of tokens
|
|
---@field pos number Current token position
|
|
local Parser = {}
|
|
Parser.__index = Parser
|
|
|
|
--- Create a new parser
|
|
---@param tokens table Array of tokens
|
|
---@return Parser
|
|
function Parser.new(tokens)
|
|
local self = setmetatable({}, Parser)
|
|
self.tokens = tokens
|
|
self.pos = 1
|
|
return self
|
|
end
|
|
|
|
--- Get current token
|
|
---@return table token Current token
|
|
function Parser:current()
|
|
return self.tokens[self.pos]
|
|
end
|
|
|
|
--- Advance to next token
|
|
function Parser:advance()
|
|
self.pos = self.pos + 1
|
|
end
|
|
|
|
--- Parse expression (handles + and -)
|
|
---@return table ast Abstract syntax tree node
|
|
function Parser:parseExpression()
|
|
local left = self:parseTerm()
|
|
|
|
while self:current().type == TokenType.PLUS or self:current().type == TokenType.MINUS do
|
|
local op = self:current().type
|
|
self:advance()
|
|
local right = self:parseTerm()
|
|
left = {
|
|
type = op == TokenType.PLUS and "add" or "subtract",
|
|
left = left,
|
|
right = right,
|
|
}
|
|
end
|
|
|
|
return left
|
|
end
|
|
|
|
--- Parse term (handles * and /)
|
|
---@return table ast Abstract syntax tree node
|
|
function Parser:parseTerm()
|
|
local left = self:parseFactor()
|
|
|
|
while self:current().type == TokenType.MULTIPLY or self:current().type == TokenType.DIVIDE do
|
|
local op = self:current().type
|
|
self:advance()
|
|
local right = self:parseFactor()
|
|
left = {
|
|
type = op == TokenType.MULTIPLY and "multiply" or "divide",
|
|
left = left,
|
|
right = right,
|
|
}
|
|
end
|
|
|
|
return left
|
|
end
|
|
|
|
--- Parse factor (handles numbers and parentheses)
|
|
---@return table ast Abstract syntax tree node
|
|
function Parser:parseFactor()
|
|
local token = self:current()
|
|
|
|
if token.type == TokenType.NUMBER then
|
|
self:advance()
|
|
return {
|
|
type = "number",
|
|
value = token.value,
|
|
unit = token.unit,
|
|
}
|
|
elseif token.type == TokenType.LPAREN then
|
|
self:advance()
|
|
local expr = self:parseExpression()
|
|
if self:current().type ~= TokenType.RPAREN then
|
|
error("Expected closing parenthesis")
|
|
end
|
|
self:advance()
|
|
return expr
|
|
else
|
|
error("Unexpected token: " .. token.type)
|
|
end
|
|
end
|
|
|
|
--- Parse the tokens into an AST
|
|
---@return table ast Abstract syntax tree
|
|
function Parser:parse()
|
|
local ast = self:parseExpression()
|
|
if self:current().type ~= TokenType.EOF then
|
|
error("Unexpected tokens after expression")
|
|
end
|
|
return ast
|
|
end
|
|
|
|
--- Create a calc expression object that can be resolved later
|
|
--- This is the main API function that users call
|
|
---@param expr string The calc expression (e.g., "50% - 10vw")
|
|
---@return table calcObject A calc expression object with AST
|
|
function Calc.new(expr)
|
|
-- Tokenize
|
|
local tokens, err = tokenize(expr)
|
|
if not tokens then
|
|
if Calc._ErrorHandler then
|
|
Calc._ErrorHandler:warn("Calc", "VAL_006", {
|
|
expression = expr,
|
|
error = err,
|
|
})
|
|
end
|
|
-- Return a fallback calc object that resolves to 0
|
|
return {
|
|
_isCalc = true,
|
|
_expr = expr,
|
|
_ast = nil,
|
|
_error = err,
|
|
}
|
|
end
|
|
|
|
-- Parse
|
|
local parser = Parser.new(tokens)
|
|
local success, ast = pcall(function()
|
|
return parser:parse()
|
|
end)
|
|
|
|
if not success then
|
|
if Calc._ErrorHandler then
|
|
Calc._ErrorHandler:warn("Calc", "VAL_006", {
|
|
expression = expr,
|
|
error = ast, -- ast contains error message on failure
|
|
})
|
|
end
|
|
-- Return a fallback calc object that resolves to 0
|
|
return {
|
|
_isCalc = true,
|
|
_expr = expr,
|
|
_ast = nil,
|
|
_error = ast,
|
|
}
|
|
end
|
|
|
|
return {
|
|
_isCalc = true,
|
|
_expr = expr,
|
|
_ast = ast,
|
|
}
|
|
end
|
|
|
|
--- Check if a value is a calc expression
|
|
---@param value any The value to check
|
|
---@return boolean isCalc True if value is a calc expression
|
|
function Calc.isCalc(value)
|
|
return type(value) == "table" and value._isCalc == true
|
|
end
|
|
|
|
--- Resolve a calc expression to pixel value
|
|
---@param calcObj table The calc expression object
|
|
---@param viewportWidth number Viewport width in pixels
|
|
---@param viewportHeight number Viewport height in pixels
|
|
---@param parentSize number? Parent dimension for percentage units
|
|
---@param elementWidth number? Element width for ew units
|
|
---@param elementHeight number? Element height for eh units
|
|
---@return number resolvedValue Resolved pixel value
|
|
function Calc.resolve(calcObj, viewportWidth, viewportHeight, parentSize, elementWidth, elementHeight)
|
|
if not calcObj._ast then
|
|
-- Error during parsing, return 0
|
|
return 0
|
|
end
|
|
|
|
--- Evaluate AST node recursively
|
|
---@param node table AST node
|
|
---@return number value Evaluated value in pixels
|
|
local function evaluate(node)
|
|
if node.type == "number" then
|
|
-- Convert unit to pixels
|
|
local value = node.value
|
|
local unit = node.unit
|
|
|
|
if unit == "px" then
|
|
return value
|
|
elseif unit == "%" then
|
|
if not parentSize then
|
|
if Calc._ErrorHandler then
|
|
Calc._ErrorHandler:warn("Calc", "LAY_003", {
|
|
unit = "%",
|
|
issue = "parent dimension not available",
|
|
})
|
|
end
|
|
return 0
|
|
end
|
|
return (value / 100) * parentSize
|
|
elseif unit == "vw" then
|
|
return (value / 100) * viewportWidth
|
|
elseif unit == "vh" then
|
|
return (value / 100) * viewportHeight
|
|
elseif unit == "ew" then
|
|
if not elementWidth then
|
|
if Calc._ErrorHandler then
|
|
Calc._ErrorHandler:warn("Calc", "LAY_003", {
|
|
unit = "ew",
|
|
issue = "element width not available",
|
|
})
|
|
end
|
|
return 0
|
|
end
|
|
return (value / 100) * elementWidth
|
|
elseif unit == "eh" then
|
|
if not elementHeight then
|
|
if Calc._ErrorHandler then
|
|
Calc._ErrorHandler:warn("Calc", "LAY_003", {
|
|
unit = "eh",
|
|
issue = "element height not available",
|
|
})
|
|
end
|
|
return 0
|
|
end
|
|
return (value / 100) * elementHeight
|
|
else
|
|
return 0
|
|
end
|
|
elseif node.type == "add" then
|
|
return evaluate(node.left) + evaluate(node.right)
|
|
elseif node.type == "subtract" then
|
|
return evaluate(node.left) - evaluate(node.right)
|
|
elseif node.type == "multiply" then
|
|
return evaluate(node.left) * evaluate(node.right)
|
|
elseif node.type == "divide" then
|
|
local divisor = evaluate(node.right)
|
|
if divisor == 0 then
|
|
if Calc._ErrorHandler then
|
|
Calc._ErrorHandler:warn("Calc", "VAL_006", {
|
|
expression = calcObj._expr,
|
|
error = "Division by zero",
|
|
})
|
|
end
|
|
return 0
|
|
end
|
|
return evaluate(node.left) / divisor
|
|
else
|
|
return 0
|
|
end
|
|
end
|
|
|
|
return evaluate(calcObj._ast)
|
|
end
|
|
|
|
return Calc
|