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.
94 lines
4.0 KiB
Python
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])}
|