Files
pgames-manager/backend/app/converter/nani_expr.py
T
mrleo1nid c8d83b3fe7 feat: add mod editing, revision merges and secure game storage
Add immutable revisions, PGVN editor, asset replacement, merge and rebase workflows, authenticated media delivery and reachability GC. Harden the script interpreter, pin unrpyc and remove tracked build metadata. Include current compatibility checks, Naninovel import and regression tests.
2026-10-25 04:42:48 +03:00

94 lines
4.0 KiB
Python

"""Parse Naninovel expressions into data, never Python/JavaScript source code."""
import re
TOKEN = re.compile(r'''\s*(?:(\d+(?:\.\d*)?(?:[eE][+-]?\d+)?|\.\d+)|([A-Za-z_]\w*)|("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|(&&|\|\||==|!=|<>|<=|>=|<<|>>|[()+\-*/%<>=!~?:,&|^]))''')
PRECEDENCE = {"or": 2, "||": 2, "and": 3, "&&": 3, "|": 4, "^": 5,
"&": 6, "==": 7, "=": 7, "!=": 7, "<>": 7, "<": 8, ">": 8,
"<=": 8, ">=": 8, "<<": 9, ">>": 9, "+": 10, "-": 10,
"*": 11, "/": 11, "%": 11}
FUNCTIONS = {"Random", "random", "Rand", "GetRandValue", "Min", "Max", "Abs", "Round", "Pow"}
def parse(source: str, functions: set[str] | None = None) -> list:
source = source.strip().removesuffix(";").strip()
tokens, offset = [], 0
while offset < len(source):
match = TOKEN.match(source, offset)
if not match:
raise ValueError(f"Не поддерживается выражение Naninovel: {source[offset:offset + 60]}")
tokens.append((next(i for i, v in enumerate(match.groups()) if v is not None),
next(v for v in match.groups() if v is not None)))
offset = match.end()
position = 0
allowed = FUNCTIONS if functions is None else functions
def peek():
return tokens[position][1] if position < len(tokens) else ""
def take(expected=None):
nonlocal position
if position >= len(tokens) or (expected is not None and peek() != expected):
raise ValueError(f"Ожидалось {expected or 'выражение'}: {source}")
token = tokens[position]
position += 1
return token
def expression(minimum=1):
kind, token = take()
if token in {"-", "+", "!", "not", "~"}:
left = ["unary", token, expression(12)]
elif token == "(":
left = expression()
take(")")
elif kind == 0:
left = ["const", float(token) if any(c in token for c in ".eE") else int(token)]
elif kind == 2:
# Decode only conventional escapes; preserve all Unicode prose.
left = ["const", re.sub(r"\\([\\\"'nrt])", lambda m:
{"n": "\n", "r": "\r", "t": "\t"}.get(m[1], m[1]), token[1:-1])]
elif kind == 1:
if token.lower() in {"true", "false", "null"}:
left = ["const", {"true": True, "false": False, "null": None}[token.lower()]]
elif peek() == "(":
if token not in allowed:
raise ValueError(f"Не поддерживается функция Naninovel: {token}")
take("(")
args = []
if peek() != ")":
while True:
args.append(expression())
if peek() != ",":
break
take(",")
take(")")
left = ["call", token, args]
else:
left = ["name", token]
else:
raise ValueError(f"Неожиданный токен {token}: {source}")
while peek() in PRECEDENCE and PRECEDENCE[peek()] >= minimum:
op = take()[1]
left = ["bin", op, left, expression(PRECEDENCE[op] + 1)]
if minimum <= 1 and peek() == "?":
take("?")
yes = expression()
take(":")
left = ["if", left, yes, expression()]
return left
ast = expression()
if position != len(tokens):
raise ValueError(f"Лишние токены в выражении Naninovel: {source}")
return ast
def expr(source: str) -> dict:
return {"k": "nani", "ast": parse(source)}
def assignment(source: str) -> dict:
match = re.fullmatch(r"\s*([A-Za-z_]\w*)\s*=(?!=)(.*?)\s*;?\s*", source, re.S)
if not match:
raise ValueError(f"Не поддерживается присваивание Naninovel: {source}")
return {"op": "set", "t": {"k": "name", "id": match[1]}, "v": expr(match[2])}