feat: add native Tomie adapter and strict import compatibility checks

This commit is contained in:
mrleo1nid committed 2026-10-25 11:02:52 +03:00
1 parent c8d83b3fe7
commit 57a4edcbfd
45 files changed
+2985 -71

No files matched your search

+55 -14
View File
@@ -1,5 +1,6 @@
"""Parse Naninovel expressions into data, never Python/JavaScript source code."""
import re
import math
TOKEN = re.compile(r'''\s*(?:(\d+(?:\.\d*)?(?:[eE][+-]?\d+)?|\.\d+)|([A-Za-z_]\w*)|("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|(&&|\|\||==|!=|<>|<=|>=|<<|>>|[()+\-*/%<>=!~?:,&|^]))''')
PRECEDENCE = {"or": 2, "||": 2, "and": 3, "&&": 3, "|": 4, "^": 5,
@@ -7,12 +8,41 @@ PRECEDENCE = {"or": 2, "||": 2, "and": 3, "&&": 3, "|": 4, "^": 5,
"<=": 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]}")
@@ -23,7 +53,7 @@ def parse(source: str, functions: set[str] | None = None) -> list:
allowed = FUNCTIONS if functions is None else functions
def peek():
return tokens[position][1] if position < len(tokens) else ""
return ("\x00string" if tokens[position][0] == 2 else tokens[position][1]) if position < len(tokens) else ""
def take(expected=None):
nonlocal position
@@ -35,22 +65,23 @@ def parse(source: str, functions: set[str] | None = None) -> list:
def expression(minimum=1):
kind, token = take()
if token in {"-", "+", "!", "not", "~"}:
if kind != 2 and token in {"-", "+", "!", "not", "~"}:
left = ["unary", token, expression(12)]
elif token == "(":
elif kind == 3 and token == "(":
left = expression()
take(")")
elif kind == 0:
left = ["const", float(token) if any(c in token for c in ".eE") else int(token)]
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:
# 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])]
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:
if token not in allowed and token.lower() != "if":
raise ValueError(f"Не поддерживается функция Naninovel: {token}")
take("(")
args = []
@@ -61,7 +92,14 @@ def parse(source: str, functions: set[str] | None = None) -> list:
break
take(",")
take(")")
left = ["call", token, args]
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:
@@ -82,12 +120,15 @@ def parse(source: str, functions: set[str] | None = None) -> list:
return ast
def expr(source: str) -> dict:
return {"k": "nani", "ast": parse(source)}
def expr(source: str, functions: set[str] | None = None) -> dict:
return {"k": "nani", "ast": parse(source, functions)}
def assignment(source: str) -> dict:
match = re.fullmatch(r"\s*([A-Za-z_]\w*)\s*=(?!=)(.*?)\s*;?\s*", source, re.S)
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}")
return {"op": "set", "t": {"k": "name", "id": match[1]}, "v": expr(match[2])}
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}}