291 lines
10 KiB
Python
291 lines
10 KiB
Python
from __future__ import annotations
|
|
import operator
|
|
import re
|
|
from typing import TYPE_CHECKING, TypeAlias, Any, cast
|
|
|
|
if TYPE_CHECKING:
|
|
Token: TypeAlias = tuple[str, str]
|
|
|
|
# === AST Node Definitions ===
|
|
|
|
_ops = {
|
|
"==": (str, operator.eq),
|
|
"!=": (str, operator.ne),
|
|
"<": (float, operator.lt),
|
|
">": (float, operator.gt),
|
|
"<=": (float, operator.le),
|
|
">=": (float, operator.ge),
|
|
}
|
|
|
|
class ExprNode:
|
|
"""Base class for all expression nodes in the AST."""
|
|
def eval(self, context: dict[str, Any]) -> Any:
|
|
raise NotImplementedError("Must implement eval in subclass")
|
|
|
|
class LiteralNode(ExprNode):
|
|
"""Represents a literal value (string, number, or regex pattern)."""
|
|
def __init__(self, value: Any) -> None:
|
|
self.value = value
|
|
|
|
def eval(self, context: dict[str, Any]) -> Any:
|
|
return self.value
|
|
|
|
class VarNode(ExprNode):
|
|
"""Represents a variable look-up in the configuration context."""
|
|
def __init__(self, name: str) -> None:
|
|
self.name = name
|
|
|
|
def eval(self, context: dict[str, Any]) -> Any:
|
|
return context.get(self.name, '0')
|
|
|
|
class IndexNode(ExprNode):
|
|
"""Represents an indexed variable look-up (e.g., nozzle_diameter[0])."""
|
|
def __init__(self, name: str, index: int) -> None:
|
|
self.name = name
|
|
self.index = index
|
|
|
|
def eval(self, context: dict[str, Any]) -> Any:
|
|
val = str(context.get(self.name, '0'))
|
|
parts = val.split(',')
|
|
if self.index >= len(parts):
|
|
return '0'
|
|
return parts[self.index].strip()
|
|
|
|
class UnaryOpNode(ExprNode):
|
|
"""Represents a unary operator (e.g., !)."""
|
|
def __init__(self, op: str, child: ExprNode) -> None:
|
|
self.op = op
|
|
self.child = child
|
|
|
|
def eval(self, context: dict[str, Any]) -> Any:
|
|
cval = self.child.eval(context)
|
|
if isinstance(cval, re.Pattern):
|
|
raise TypeError("Unary operator cannot be applied to a regex pattern.")
|
|
|
|
if self.op == "!":
|
|
try:
|
|
return not float(cval)
|
|
except (ValueError, TypeError):
|
|
return not bool(cval)
|
|
|
|
raise RuntimeError(f"Unknown unary operator: {self.op}")
|
|
|
|
class BinaryOpNode(ExprNode):
|
|
"""Represents a binary operator (e.g., ==, &&, =~)."""
|
|
def __init__(self, left: ExprNode, op: str, right: ExprNode) -> None:
|
|
self.left = left
|
|
self.op = op
|
|
self.right = right
|
|
|
|
def eval(self, context: dict[str, Any]) -> Any:
|
|
# Short-circuiting for logical operators
|
|
if self.op in ("and", "&&"):
|
|
lv = self.left.eval(context)
|
|
if not lv or lv == '0':
|
|
return False
|
|
return self.right.eval(context)
|
|
|
|
if self.op in ("or", "||"):
|
|
lv = self.left.eval(context)
|
|
if lv and lv != '0':
|
|
return True
|
|
return self.right.eval(context)
|
|
|
|
lv = self.left.eval(context)
|
|
rv = self.right.eval(context)
|
|
|
|
# Standard comparisons
|
|
if self.op in _ops:
|
|
caster, func = _ops[self.op]
|
|
if isinstance(lv, re.Pattern) or isinstance(rv, re.Pattern):
|
|
raise RuntimeError(f"Operands of {self.op} cannot be regex patterns.")
|
|
try:
|
|
return func(caster(lv), caster(rv))
|
|
except (ValueError, TypeError):
|
|
# Fallback to string comparison if cast fails
|
|
return func(str(lv), str(rv))
|
|
|
|
# Regex matching
|
|
if self.op == "=~":
|
|
if not isinstance(rv, re.Pattern):
|
|
raise TypeError("Right operand of =~ must be a regex pattern.")
|
|
pattern_str = cast(re.Pattern[str], rv)
|
|
return pattern_str.search(str(lv)) is not None
|
|
|
|
if self.op == "!~":
|
|
if not isinstance(rv, re.Pattern):
|
|
raise TypeError("Right operand of !~ must be a regex pattern.")
|
|
pattern_str = cast(re.Pattern[str], rv)
|
|
return pattern_str.search(str(lv)) is None
|
|
|
|
raise RuntimeError(f"Unknown binary operator: {self.op}")
|
|
|
|
class Parser:
|
|
"""Recursive descent parser for PrusaSlicer expressions."""
|
|
def __init__(self, text: str):
|
|
self.text = text
|
|
self.tokens = self._tokenize(text)
|
|
self.pos = 0
|
|
|
|
def _tokenize(self, text: str) -> list[Token]:
|
|
tokens: list[Token] = []
|
|
i = 0
|
|
length = len(text)
|
|
|
|
while i < length:
|
|
c = text[i]
|
|
|
|
if c.isspace():
|
|
i += 1
|
|
continue
|
|
|
|
# Fixed tokens
|
|
if c == "(": tokens.append(("LPAREN", "(")); i += 1; continue
|
|
if c == ")": tokens.append(("RPAREN", ")")); i += 1; continue
|
|
if c == "[": tokens.append(("LBRACK", "[")); i += 1; continue
|
|
if c == "]": tokens.append(("RBRACK", "]")); i += 1; continue
|
|
|
|
# Multi-character operators
|
|
if text.startswith("==", i): tokens.append(("OP", "==")); i += 2; continue
|
|
if text.startswith("!=", i): tokens.append(("OP", "!=")); i += 2; continue
|
|
if text.startswith(">=", i): tokens.append(("OP", ">=")); i += 2; continue
|
|
if text.startswith("<=", i): tokens.append(("OP", "<=")); i += 2; continue
|
|
if text.startswith("=~", i): tokens.append(("OP", "=~")); i += 2; continue
|
|
if text.startswith("!~", i): tokens.append(("OP", "!~")); i += 2; continue
|
|
if text.startswith("&&", i): tokens.append(("OP", "and")); i += 2; continue
|
|
if text.startswith("||", i): tokens.append(("OP", "or")); i += 2; continue
|
|
|
|
if c == ">": tokens.append(("OP", ">")); i += 1; continue
|
|
if c == "<": tokens.append(("OP", "<")); i += 1; continue
|
|
if c == "!": tokens.append(("OP", "!")); i += 1; continue
|
|
|
|
# Regex literal
|
|
if c == "/":
|
|
j = i + 1
|
|
while j < length:
|
|
if text[j] == "/" and text[j-1] != "\\":
|
|
break
|
|
j += 1
|
|
if j >= length:
|
|
raise SyntaxError("Unterminated regex literal.")
|
|
tokens.append(("REGEX", text[i+1:j]))
|
|
i = j + 1
|
|
continue
|
|
|
|
# String literal
|
|
if c in ("'", '"'):
|
|
quote = c
|
|
j = i + 1
|
|
while j < length:
|
|
if text[j] == quote and text[j-1] != "\\":
|
|
break
|
|
j += 1
|
|
if j >= length:
|
|
raise SyntaxError("Unterminated string literal.")
|
|
tokens.append(("STRING", text[i+1:j]))
|
|
i = j + 1
|
|
continue
|
|
|
|
# Number
|
|
if c.isdigit():
|
|
j = i
|
|
while j < length and (text[j].isdigit() or text[j] == "."):
|
|
j += 1
|
|
tokens.append(("NUMBER", text[i:j]))
|
|
i = j
|
|
continue
|
|
|
|
# Identifier or Logical keyword
|
|
if c.isalpha() or c == "_":
|
|
j = i
|
|
while j < length and (text[j].isalnum() or text[j] == "_"):
|
|
j += 1
|
|
ident = text[i:j]
|
|
if ident == "and": tokens.append(("OP", "and"))
|
|
elif ident == "or": tokens.append(("OP", "or"))
|
|
else: tokens.append(("IDENT", ident))
|
|
i = j
|
|
continue
|
|
|
|
raise SyntaxError(f"Unexpected character in expression: {c}")
|
|
|
|
return tokens
|
|
|
|
def _peek(self) -> Token | None:
|
|
return self.tokens[self.pos] if self.pos < len(self.tokens) else None
|
|
|
|
def _advance(self) -> Token:
|
|
tok = self._peek()
|
|
if not tok:
|
|
raise SyntaxError("Unexpected end of input.")
|
|
self.pos += 1
|
|
return tok
|
|
|
|
def _expect(self, ttype: str, value: str | None = None) -> Token:
|
|
tok = self._peek()
|
|
if not tok or tok[0] != ttype or (value and tok[1] != value):
|
|
raise SyntaxError(f"Expected {ttype} {value or ''}, got {tok}")
|
|
return self._advance()
|
|
|
|
def parse(self) -> ExprNode:
|
|
node = self._parse_or()
|
|
if self._peek():
|
|
raise SyntaxError(f"Trailing tokens after expression: {self._peek()}")
|
|
return node
|
|
|
|
def _parse_or(self) -> ExprNode:
|
|
left = self._parse_and()
|
|
while (tok := self._peek()) and tok[0] == "OP" and tok[1] == "or":
|
|
self._advance()
|
|
left = BinaryOpNode(left, "or", self._parse_and())
|
|
return left
|
|
|
|
def _parse_and(self) -> ExprNode:
|
|
left = self._parse_not()
|
|
while (tok := self._peek()) and tok[0] == "OP" and tok[1] == "and":
|
|
self._advance()
|
|
left = BinaryOpNode(left, "and", self._parse_not())
|
|
return left
|
|
|
|
def _parse_not(self) -> ExprNode:
|
|
if (tok := self._peek()) and tok[0] == "OP" and tok[1] == "!":
|
|
self._advance()
|
|
return UnaryOpNode("!", self._parse_not())
|
|
return self._parse_comp()
|
|
|
|
def _parse_comp(self) -> ExprNode:
|
|
left = self._parse_atom()
|
|
ops = ("==", "!=", "<", ">", "<=", ">=", "=~", "!~")
|
|
if (tok := self._peek()) and tok[0] == "OP" and tok[1] in ops:
|
|
op = tok[1]
|
|
self._advance()
|
|
left = BinaryOpNode(left, op, self._parse_atom())
|
|
return left
|
|
|
|
def _parse_atom(self) -> ExprNode:
|
|
tok = self._advance()
|
|
ttype, val = tok
|
|
|
|
if ttype in ("STRING", "NUMBER"):
|
|
return LiteralNode(val)
|
|
|
|
if ttype == "REGEX":
|
|
try:
|
|
return LiteralNode(re.compile(val))
|
|
except re.error as e:
|
|
raise SyntaxError(f"Invalid regex /{val}/: {e}")
|
|
|
|
if ttype == "IDENT":
|
|
if (nxt := self._peek()) and nxt[0] == "LBRACK":
|
|
self._advance()
|
|
idx = int(self._expect("NUMBER")[1])
|
|
self._expect("RBRACK")
|
|
return IndexNode(val, idx)
|
|
return VarNode(val)
|
|
|
|
if ttype == "LPAREN":
|
|
node = self._parse_or()
|
|
self._expect("RPAREN")
|
|
return node
|
|
|
|
raise SyntaxError(f"Unexpected token in atom: {tok}")
|