Files
pgames-manager/backend/app/converter/nani_expr.py
T
mrleo1nidandClaude Sonnet 5.5 bbbcc6a5d8 chore: add CI, lint config, login rate limit and split large modules
- Rate-limit failed logins (8 per 15 min per IP or username, 429 + Retry-After)
- Add ruff/mypy config and GitHub Actions workflow (ruff, pytest, tsc, tests, build)
- Apply ruff autofixes (import order, pyupgrade)
- Extract rpy_helpers.py, engine-util.ts, TranslationParts.tsx and
  translation-types.ts from oversized modules
- Add dependency-free React UI tests
- Refresh README and ARCHITECTURE (Naninovel/Tomie, status and plans)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 11:23:22 +03:00

135 lines
6.1 KiB
Python

"""Parse Naninovel expressions into data, never Python/JavaScript source code."""
import math
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"}
ARITY = {"Random": (1, 2), "random": (1, 2), "Rand": (1, 2), "GetRandValue": (1, 1),
"Min": (1, 100), "Max": (1, 100), "Abs": (1, 1), "Round": (1, 2), "Pow": (2, 2)}
def parse(source: str, functions: set[str] | None = None) -> list:
source = source.strip().removesuffix(";").strip()
tokens, offset = [], 0
while offset < len(source):
if source[offset].isspace():
offset += 1
continue
wrapped = source[offset] == "\\" and offset + 1 < len(source) and source[offset + 1] in "\"'"
if source[offset] in "\"'" or wrapped:
quote = source[offset + 1] if wrapped else source[offset]
offset += 2 if wrapped else 1
value, closed = [], False
while offset < len(source):
char = source[offset]
if wrapped and char == "\\" and offset + 1 < len(source) and source[offset + 1] == quote:
offset += 2
closed = True
break
if not wrapped and char == quote:
offset += 1
closed = True
break
if char == "\\" and offset + 1 < len(source):
offset += 1
char = {"n": "\n", "r": "\r", "t": "\t"}.get(source[offset], source[offset])
value.append(char)
offset += 1
if not closed:
raise ValueError("Не закрыта строка в выражении Naninovel")
tokens.append((2, "".join(value)))
continue
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 ("\x00string" if tokens[position][0] == 2 else 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 kind != 2 and token in {"-", "+", "!", "not", "~"}:
left = ["unary", token, expression(12)]
elif kind == 3 and token == "(":
left = expression()
take(")")
elif kind == 0:
value = float(token) if any(c in token for c in ".eE") else int(token)
if not math.isfinite(value):
raise ValueError("Неконечная числовая константа Naninovel")
left = ["const", value]
elif kind == 2:
left = ["const", token]
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 and token.lower() != "if":
raise ValueError(f"Не поддерживается функция Naninovel: {token}")
take("(")
args = []
if peek() != ")":
while True:
args.append(expression())
if peek() != ",":
break
take(",")
take(")")
if functions is None and token in ARITY and not ARITY[token][0] <= len(args) <= ARITY[token][1]:
raise ValueError(f"Неподдерживаемое число аргументов Naninovel: {token}")
if token.lower() == "if":
if len(args) != 3:
raise ValueError("If Naninovel требует три аргумента")
left = ["if", *args]
else:
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, functions: set[str] | None = None) -> dict:
return {"k": "nani", "ast": parse(source, functions)}
def assignment(source: str, functions: set[str] | None = None) -> dict:
match = re.fullmatch(r"\s*([A-Za-z_]\w*)\s*([+\-*/%&|^]?)=(?!=)(.*?)\s*;?\s*", source, re.S)
if not match:
raise ValueError(f"Не поддерживается присваивание Naninovel: {source}")
ast = parse(match[3], functions)
if match[2]:
ast = ["bin", match[2], ["name", match[1]], ast]
return {"op": "set", "t": {"k": "name", "id": match[1]}, "v": {"k": "nani", "ast": ast}}