120 lines
7.0 KiB
TypeScript
120 lines
7.0 KiB
TypeScript
// Runtime parser for dynamically composed expressions. This accepts the same
|
|
// bounded language as the import parser; it never evaluates JavaScript source.
|
|
import type { NaniAst } from "./naninovel";
|
|
|
|
const precedence: Record<string, number> = { or: 2, "||": 2, and: 3, "&&": 3, "|": 4, "^": 5,
|
|
"&": 6, "==": 7, "=": 7, "!=": 7, "<>": 7, "<": 8, ">": 8, "<=": 8, ">=": 8,
|
|
"<<": 9, ">>": 9, "+": 10, "-": 10, "*": 11, "/": 11, "%": 11 };
|
|
|
|
export function parseNani(source: string, functions: Set<string>): NaniAst {
|
|
if (source.length > 30000) throw new Error("Naninovel: слишком длинное выражение");
|
|
source = source.trim().replace(/;$/, "").trim();
|
|
const tokens: { value: any; kind: string }[] = [];
|
|
for (let offset = 0; offset < source.length;) {
|
|
if (/\s/.test(source[offset])) { offset++; continue; }
|
|
const wrapped = source[offset] === "\\" && ["\"", "'"].includes(source[offset + 1]);
|
|
if (["\"", "'"].includes(source[offset]) || wrapped) {
|
|
const quote = source[offset + (wrapped ? 1 : 0)];
|
|
offset += wrapped ? 2 : 1;
|
|
let value = "", closed = false;
|
|
while (offset < source.length) {
|
|
let char = source[offset];
|
|
if (wrapped && char === "\\" && source[offset + 1] === quote) { offset += 2; closed = true; break; }
|
|
if (!wrapped && char === quote) { offset++; closed = true; break; }
|
|
if (char === "\\" && offset + 1 < source.length) {
|
|
char = source[++offset];
|
|
char = ({ n: "\n", r: "\r", t: "\t" } as Record<string, string>)[char] ?? char;
|
|
}
|
|
value += char; offset++;
|
|
}
|
|
if (!closed) throw new Error("Naninovel: не закрыта строка выражения");
|
|
tokens.push({ kind: "string", value }); continue;
|
|
}
|
|
const rest = source.slice(offset);
|
|
const numeric = rest.match(/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/);
|
|
const name = rest.match(/^[A-Za-z_]\w*/);
|
|
const operator = rest.match(/^(?:&&|\|\||==|!=|<>|<=|>=|<<|>>|[()+\-*/%<>=!~?:,&|^])/);
|
|
const found = numeric ?? name ?? operator;
|
|
if (!found) throw new Error(`Naninovel: неизвестный токен ${rest.slice(0, 40)}`);
|
|
tokens.push({ kind: numeric ? "number" : name ? "name" : "operator", value: numeric ? Number(found[0]) : found[0] });
|
|
offset += found[0].length;
|
|
}
|
|
let cursor = 0, depth = 0;
|
|
const peek = () => tokens[cursor]?.kind === "string" ? "\u0000string" : tokens[cursor]?.value ?? "";
|
|
const take = (expected?: string) => {
|
|
if (cursor >= tokens.length || (expected !== undefined && peek() !== expected)) throw new Error(`Naninovel: ожидалось ${expected ?? "выражение"}`);
|
|
return tokens[cursor++];
|
|
};
|
|
const expression = (minimum = 1): NaniAst => {
|
|
if (++depth > 64) throw new Error("Naninovel: слишком глубокое выражение");
|
|
const token = take(), value = token.value;
|
|
let left: NaniAst;
|
|
if (token.kind !== "string" && ["-", "+", "!", "not", "~"].includes(value)) left = ["unary", value, expression(12)];
|
|
else if (token.kind === "operator" && value === "(") { left = expression(); take(")"); }
|
|
else if (["number", "string"].includes(token.kind)) {
|
|
if (token.kind === "number" && !Number.isFinite(value)) throw new Error("Naninovel: неконечное число");
|
|
left = ["const", value];
|
|
} else if (token.kind === "name") {
|
|
if (["true", "false", "null"].includes(value.toLowerCase())) left = ["const", { true: true, false: false, null: null }[value.toLowerCase() as "true"]];
|
|
else if (peek() === "(") {
|
|
if (!functions.has(value) && value.toLowerCase() !== "if") throw new Error(`Naninovel: функция ${value} не поддерживается`);
|
|
take("(");
|
|
const args: NaniAst[] = [];
|
|
if (peek() !== ")") while (true) { args.push(expression()); if (peek() !== ",") break; take(","); }
|
|
take(")");
|
|
if (value.toLowerCase() === "if") {
|
|
if (args.length !== 3) throw new Error("Naninovel: If требует три аргумента");
|
|
left = ["if", ...args];
|
|
} else left = ["call", value, args];
|
|
} else left = ["name", value];
|
|
} else throw new Error(`Naninovel: некорректный токен ${value}`);
|
|
while (Object.hasOwn(precedence, peek()) && precedence[peek()] >= minimum) {
|
|
const op = take().value;
|
|
left = ["bin", op, left, expression(precedence[op] + 1)];
|
|
}
|
|
if (minimum <= 1 && peek() === "?") { take("?"); const yes = expression(); take(":"); left = ["if", left, yes, expression()]; }
|
|
depth--; return left;
|
|
};
|
|
const ast = expression();
|
|
if (cursor !== tokens.length) throw new Error("Naninovel: лишние токены выражения");
|
|
return ast;
|
|
}
|
|
|
|
export interface NaniCommand { op: string; args: Record<string, any>; line?: number; tid?: string }
|
|
|
|
export function inlineCommands(source: string | string[], line = -1): NaniCommand[] {
|
|
let lines: string[];
|
|
if (Array.isArray(source)) lines = source;
|
|
else {
|
|
lines = []; let start = 0, depth = 0, quote = "";
|
|
for (let index = 0; index < source.length; index++) {
|
|
const char = source[index];
|
|
if (quote) { if (char === "\\") index++; else if (char === quote) quote = ""; }
|
|
else if (["\"", "'"].includes(char)) quote = char;
|
|
else if (char === "(" || char === "{") depth++;
|
|
else if (char === ")" || char === "}") depth--;
|
|
else if (char === "," && depth === 0) { lines.push(source.slice(start, index)); start = index + 1; }
|
|
}
|
|
if (depth !== 0 || quote) throw new Error("Naninovel: незакрытая команда выбора");
|
|
lines.push(source.slice(start));
|
|
}
|
|
return lines.filter((line) => line.trim()).map((source) => {
|
|
source = source.trim();
|
|
if (!source.startsWith("@")) {
|
|
const speaker = source.match(/^([^:]{1,24}): (.*)$/s);
|
|
return { op: "PrintText", line, args: { Text: { template: speaker ? speaker[2] : source }, AuthorId: speaker?.[1] ?? "" } };
|
|
}
|
|
const [, name, body = ""] = source.match(/^(@\S+)(?:\s+(.*))?$/s)!;
|
|
const template = { template: body };
|
|
if (name === "@set") return { op: "SetCustomVariable", args: { Expression: template }, line };
|
|
if (["@goto", "@gosub"].includes(name)) return { op: name === "@goto" ? "Goto" : "Gosub", args: { Path: template }, line };
|
|
if (name === "@return") return { op: "Return", args: {}, line };
|
|
if (name === "@hideChars") return { op: "HideAllCharacters", args: { Duration: 0 }, line };
|
|
if (["@addItem", "@additem", "@removeItem"].includes(name)) return { op: name === "@removeItem" ? "RemoveItem" : "AddItem",
|
|
args: { ItemId: { template: body.split(/\s/, 1)[0] }, Amount: { template: body.match(/amount:([^ ]+)/)?.[1] ?? "1" } }, line };
|
|
if (name === "@voice") return { op: "PlayVoice", args: { VoicePath: { template: body.split(/\s/, 1)[0] } }, line };
|
|
if (name === "@hide") return { op: "HideActors", args: { ActorIds: body.split(",").filter(Boolean) }, line };
|
|
throw new Error(`Naninovel: неизвестная команда выбора ${name}`);
|
|
});
|
|
}
|