296 lines
11 KiB
JavaScript
296 lines
11 KiB
JavaScript
"use strict";
|
|
/* JS-порт движка калькулятора: токенизация -> AST -> eval.
|
|
Правила и тексты ошибок — как в Python parser.py/nodes.py. */
|
|
const Calc = (() => {
|
|
|
|
class CalcError extends Error {
|
|
constructor(message, pos) {
|
|
super(message);
|
|
this.name = "CalcError";
|
|
this.pos = pos == null ? null : pos;
|
|
}
|
|
}
|
|
|
|
const CONSTANTS = { pi: Math.PI, e: Math.E, tau: Math.PI * 2, inf: Infinity };
|
|
const ARITIES = {
|
|
sqrt: [1], abs: [1], exp: [1], ln: [1], log: [1, 2], log2: [1], log10: [1],
|
|
round: [1, 2], floor: [1], ceil: [1], sin: [1], cos: [1], tan: [1],
|
|
asin: [1], acos: [1], atan: [1],
|
|
};
|
|
|
|
// Банковское округление (как Python round): половина — к чётному.
|
|
function roundHalfEven(x, nd) {
|
|
const p = Math.pow(10, nd || 0);
|
|
const y = x * p;
|
|
const f = Math.floor(y);
|
|
const d = y - f;
|
|
let r;
|
|
if (d > 0.5) r = f + 1;
|
|
else if (d < 0.5) r = f;
|
|
else r = (f % 2 === 0) ? f : f + 1;
|
|
return r / p;
|
|
}
|
|
|
|
function applyFn(name, args) {
|
|
const x = args[0];
|
|
switch (name) {
|
|
case "sqrt":
|
|
if (x < 0) throw new CalcError("Доменная ошибка функции: sqrt от отрицательного");
|
|
return Math.sqrt(x);
|
|
case "abs": return Math.abs(x);
|
|
case "exp": return Math.exp(x);
|
|
case "ln":
|
|
if (x <= 0) throw new CalcError("Доменная ошибка функции: ln");
|
|
return Math.log(x);
|
|
case "log": {
|
|
if (x <= 0) throw new CalcError("Доменная ошибка функции: log");
|
|
const base = args.length === 2 ? args[1] : Math.E;
|
|
if (base <= 0 || base === 1) throw new CalcError("Доменная ошибка функции: log (основание)");
|
|
return Math.log(x) / Math.log(base);
|
|
}
|
|
case "log2":
|
|
if (x <= 0) throw new CalcError("Доменная ошибка функции: log2");
|
|
return Math.log2(x);
|
|
case "log10":
|
|
if (x <= 0) throw new CalcError("Доменная ошибка функции: log10");
|
|
return Math.log10(x);
|
|
case "round":
|
|
return roundHalfEven(x, args.length === 2 ? Math.trunc(args[1]) : 0);
|
|
case "floor": return Math.floor(x);
|
|
case "ceil": return Math.ceil(x);
|
|
case "sin": return Math.sin(x);
|
|
case "cos": return Math.cos(x);
|
|
case "tan": return Math.tan(x);
|
|
case "asin":
|
|
if (x < -1 || x > 1) throw new CalcError("Доменная ошибка функции: asin");
|
|
return Math.asin(x);
|
|
case "acos":
|
|
if (x < -1 || x > 1) throw new CalcError("Доменная ошибка функции: acos");
|
|
return Math.acos(x);
|
|
case "atan": return Math.atan(x);
|
|
default: throw new CalcError(`Неизвестная функция: '${name}'`);
|
|
}
|
|
}
|
|
|
|
const NUM_RE = /^[0-9][0-9_]*(?:\.[0-9_]*)?(?:[eE][+-]?[0-9]+)?|^\.[0-9][0-9_]*(?:[eE][+-]?[0-9]+)?/;
|
|
const ID_START = /[a-zA-Z_]/, ID_CHAR = /[a-zA-Z0-9_]/;
|
|
|
|
function tokenize(src) {
|
|
const toks = [];
|
|
let i = 0;
|
|
while (i < src.length) {
|
|
const c = src[i];
|
|
if (c === " " || c === "\t" || c === "\n") { i++; continue; }
|
|
if ((c >= "0" && c <= "9") || c === ".") {
|
|
const m = NUM_RE.exec(src.slice(i));
|
|
if (!m) throw new CalcError(`Недопустимый символ: '${c}' (позиция ${i + 1})`, i);
|
|
toks.push({ t: "NUM", v: parseFloat(m[0].replace(/_/g, "")), p: i });
|
|
i += m[0].length;
|
|
continue;
|
|
}
|
|
if (ID_START.test(c)) {
|
|
let j = i;
|
|
while (j < src.length && ID_CHAR.test(src[j])) j++;
|
|
toks.push({ t: "ID", v: src.slice(i, j).toLowerCase(), p: i });
|
|
i = j;
|
|
continue;
|
|
}
|
|
if (c === "*" && src[i + 1] === "*") { toks.push({ t: "**", v: "**", p: i }); i += 2; continue; }
|
|
if (c === "/" && src[i + 1] === "/") { toks.push({ t: "//", v: "//", p: i }); i += 2; continue; }
|
|
if ("+-*/%".includes(c)) { toks.push({ t: "OP", v: c, p: i }); i++; continue; }
|
|
if (c === "(") { toks.push({ t: "LPAREN", v: "(", p: i }); i++; continue; }
|
|
if (c === ")") { toks.push({ t: "RPAREN", v: ")", p: i }); i++; continue; }
|
|
if (c === ",") { toks.push({ t: "COMMA", v: ",", p: i }); i++; continue; }
|
|
throw new CalcError(`Недопустимый символ: '${c}' (позиция ${i + 1})`, i);
|
|
}
|
|
return toks;
|
|
}
|
|
|
|
class Parser {
|
|
constructor(toks) { this.toks = toks; this.pos = 0; }
|
|
_peek() { return this.toks[this.pos] || null; }
|
|
_adv() {
|
|
const t = this._peek();
|
|
if (!t) throw new CalcError("Неожиданный конец выражения");
|
|
this.pos++;
|
|
return t;
|
|
}
|
|
_startsOperand(t) { return !!t && (t.t === "NUM" || t.t === "LPAREN" || t.t === "ID"); }
|
|
|
|
parse() {
|
|
if (!this.toks.length) throw new CalcError("Пустое выражение");
|
|
const node = this._expr();
|
|
if (this.pos < this.toks.length) throw new CalcError("Лишние токены в выражении");
|
|
return node;
|
|
}
|
|
|
|
_expr() {
|
|
let left = this._term();
|
|
for (;;) {
|
|
const t = this._peek();
|
|
if (t && t.t === "OP" && (t.v === "+" || t.v === "-")) {
|
|
this.pos++;
|
|
left = { k: "bin", op: t.v, l: left, r: this._term() };
|
|
} else break;
|
|
}
|
|
return left;
|
|
}
|
|
|
|
_term() {
|
|
let left = this._factor();
|
|
for (;;) {
|
|
const t = this._peek();
|
|
if (!t) break;
|
|
if (t.t === "OP" && (t.v === "*" || t.v === "/")) {
|
|
this.pos++; left = { k: "bin", op: t.v, l: left, r: this._factor() }; continue;
|
|
}
|
|
if (t.t === "//") {
|
|
this.pos++; left = { k: "bin", op: "//", l: left, r: this._factor() }; continue;
|
|
}
|
|
// '%': бинарный остаток, только если далее начинается операнд
|
|
if (t.t === "OP" && t.v === "%" && this._startsOperand(this.toks[this.pos + 1])) {
|
|
this.pos++; left = { k: "bin", op: "%", l: left, r: this._factor() }; continue;
|
|
}
|
|
break;
|
|
}
|
|
return left;
|
|
}
|
|
|
|
_factor() {
|
|
let base;
|
|
const t = this._peek();
|
|
if (t && t.t === "OP" && (t.v === "+" || t.v === "-")) {
|
|
this.pos++;
|
|
const operand = this._factor();
|
|
base = t.v === "-" ? { k: "un", x: operand } : operand;
|
|
} else {
|
|
base = this._postfix();
|
|
}
|
|
const nx = this._peek();
|
|
if (nx && nx.t === "**") {
|
|
this.pos++;
|
|
return { k: "pow", b: base, x: this._factor() }; // правосторонняя
|
|
}
|
|
return base;
|
|
}
|
|
|
|
_postfix() {
|
|
let node = this._atom();
|
|
for (;;) {
|
|
const t = this._peek();
|
|
if (t && t.t === "OP" && t.v === "%" && !this._startsOperand(this.toks[this.pos + 1])) {
|
|
this.pos++;
|
|
node = { k: "pct", x: node };
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
return node;
|
|
}
|
|
|
|
_atom() {
|
|
const t = this._adv();
|
|
if (t.t === "NUM") return { k: "num", v: t.v };
|
|
if (t.t === "ID") {
|
|
const name = t.v;
|
|
const nx = this._peek();
|
|
if (nx && nx.t === "LPAREN") return this._call(name);
|
|
if (ARITIES[name]) throw new CalcError(`Функция '${name}' требует скобок: ${name}(...)`);
|
|
return { k: "id", name };
|
|
}
|
|
if (t.t === "LPAREN") {
|
|
const node = this._expr();
|
|
const close = this._peek();
|
|
if (!close || close.t !== "RPAREN") throw new CalcError("Не хватает закрывающей скобки ')'");
|
|
this.pos++;
|
|
return node;
|
|
}
|
|
if (t.t === "OP") throw new CalcError("Ожидалось число, константа, функция или скобку");
|
|
if (t.t === "**" || t.t === "//") throw new CalcError("Ожидалось число, константа, функция или скобку");
|
|
throw new CalcError(`Лишние токены в выражении`);
|
|
}
|
|
|
|
_call(name) {
|
|
if (!ARITIES[name]) throw new CalcError(`Неизвестная функция: '${name}'`);
|
|
this.pos++; // съедаем '('
|
|
const args = [];
|
|
const t = this._peek();
|
|
if (t && t.t === "RPAREN") {
|
|
this.pos++;
|
|
throw new CalcError(`'${name}' требует аргументов`);
|
|
}
|
|
for (;;) {
|
|
args.push(this._expr());
|
|
const c = this._peek();
|
|
if (c && c.t === "COMMA") { this.pos++; continue; }
|
|
break;
|
|
}
|
|
const close = this._peek();
|
|
if (!close || close.t !== "RPAREN") {
|
|
throw new CalcError(`Не хватает закрывающей скобки ')' в '${name}'`);
|
|
}
|
|
this.pos++;
|
|
if (!ARITIES[name].includes(args.length)) {
|
|
throw new CalcError(`'${name}': ожидалось аргументов ${ARITIES[name].join("/")}, получено ${args.length}`);
|
|
}
|
|
return { k: "call", name, args };
|
|
}
|
|
}
|
|
|
|
function _eval(node, env) {
|
|
switch (node.k) {
|
|
case "num": return node.v;
|
|
case "un": return -_eval(node.x, env);
|
|
case "bin": {
|
|
const a = _eval(node.l, env), b = _eval(node.r, env);
|
|
switch (node.op) {
|
|
case "+": return a + b;
|
|
case "-": return a - b;
|
|
case "*": return a * b;
|
|
case "/":
|
|
if (b === 0) throw new CalcError("Деление на ноль");
|
|
return a / b;
|
|
case "//":
|
|
if (b === 0) throw new CalcError("Деление на ноль");
|
|
return Math.floor(a / b);
|
|
case "%":
|
|
if (b === 0) throw new CalcError("Деление на ноль");
|
|
return ((a % b) + b) % b; // floor-остаток как в Python
|
|
}
|
|
throw new CalcError("Неизвестный оператор");
|
|
}
|
|
case "pow": {
|
|
const a = _eval(node.b, env), b = _eval(node.x, env);
|
|
const r = Math.pow(a, b);
|
|
if (Number.isNaN(r)) throw new CalcError("Недопустимая степень (математическая доменная ошибка)");
|
|
return r;
|
|
}
|
|
case "pct": return _eval(node.x, env) / 100;
|
|
case "call": return applyFn(node.name, node.args.map((a) => _eval(a, env)));
|
|
case "id": {
|
|
if (Object.prototype.hasOwnProperty.call(env, node.name)) return env[node.name];
|
|
if (node.name in CONSTANTS) return CONSTANTS[node.name];
|
|
throw new CalcError(`Неизвестный идентификатор: '${node.name}'`);
|
|
}
|
|
}
|
|
throw new CalcError("Неизвестный узел AST");
|
|
}
|
|
|
|
// Формат как Python ".12g": 12 значащих цифр, без хвостовых нулей.
|
|
function format(x) {
|
|
if (Number.isNaN(x)) return "nan";
|
|
if (!Number.isFinite(x)) return x > 0 ? "inf" : "-inf";
|
|
if (Number.isInteger(x) && Math.abs(x) < 1e21) return String(x);
|
|
return String(Number(x.toPrecision(12)));
|
|
}
|
|
|
|
function evaluate(expr, env) {
|
|
const ast = new Parser(tokenize(expr)).parse();
|
|
return _eval(ast, env || {});
|
|
}
|
|
|
|
return { CalcError, evaluate, format, CONSTANTS, ARITIES };
|
|
})();
|
|
|
|
if (typeof module !== "undefined" && module.exports) module.exports = Calc;
|