summarum/src/engine/index.ts
Horizontal42 f55266f70b v1.1.0: bug fixes, atomic writes, safe quit, .sum files, CI
Engine: fix BigInt crash on NaN, KM/KG case-insensitive units, space-grouped
and comma-decimal number parsing, variable-vs-unit shadowing, small value
display (1 mm in km), DST-safe date arithmetic, month-end clamp, log mustTouch,
avg counted, 50% of 50%, 100 USD + 20 after conversion, percent power.

Rust: atomic writes (temp+rename) for documents.json, safe quit (app-quit event
+ 2s watchdog so tray-quit doesn't lose edits), .sum file association + open,
migration safety (canonicalize + all_copied guard), opener API for backups and
extensions folders.

UI: decimal separator setting, hotkey rollback on failure, corrupted JSON guard,
ru welcome sheet, orange cursor visibility.

CI: windows-latest workflow (tsc + vitest + cargo clippy).
2026-06-11 12:57:39 +03:00

220 lines
7.7 KiB
TypeScript

// Public facade: evaluates whole documents line by line and exposes the
// extension API (numi.addUnit / addFunction / setVariable).
import { Decimal, EngineSettings, EvalError, Quantity, Unit, Value, defaultSettings, qty } from "./types";
import { Registry, buildRegistry, Completion } from "./registry";
import { tokenize, Token } from "./tokenizer";
import { parseLine } from "./parser";
import { evaluate, EvalCtx, toBase, fromBase } from "./evaluator";
import { formatValue } from "./formatter";
import snapshot from "./rates-snapshot.json";
import { CRYPTO } from "./extraunits";
const cryptoSnapshot: Record<string, number> = {};
for (const c of CRYPTO) cryptoSnapshot[c.code] = 1 / c.snapshotUsd;
export interface LineResult {
/** formatted result, null when the line has none (text, comment, header, error) */
text: string | null;
value: Value | null;
kind: "empty" | "header" | "comment" | "normal";
/** tokens, for syntax highlighting */
tokens: Token[];
/** offset of "//" comment start within the line, if any */
commentStart: number | null;
}
export interface ExtensionUnitSpec {
id: string;
phrases: string;
baseUnitId: string;
format?: string;
ratio: number;
}
export interface ExtensionValue {
double: number;
unitId?: string;
}
export class SumEngine {
reg: Registry;
settings: EngineSettings;
/** variables defined by extensions, persist across evaluations */
private globals = new Map<string, Value>();
constructor(settings: Partial<EngineSettings> = {}) {
this.settings = { ...defaultSettings, ...settings };
this.reg = buildRegistry();
this.reg.setRates({ ...snapshot.rates, ...cryptoSnapshot });
}
setRates(rates: Record<string, number | string>): void {
// snapshot fills codes the live API doesn't return (e.g. crypto when CoinGecko fails)
this.reg.setRates({ ...snapshot.rates, ...cryptoSnapshot, ...rates });
}
updateSettings(patch: Partial<EngineSettings>): void {
this.settings = { ...this.settings, ...patch };
}
completions(): Completion[] {
return this.reg.completions;
}
evaluateDocument(text: string): LineResult[] {
const lines = text.split("\n");
const results: LineResult[] = [];
const vars = new Map<string, Value>(this.globals);
const lineValues: (Value | null)[] = [];
const lineKinds: ("empty" | "header" | "normal")[] = [];
for (let i = 0; i < lines.length; i++) {
const rawLine = lines[i];
const commentStart = rawLine.indexOf("//");
const line = commentStart >= 0 ? rawLine.slice(0, commentStart) : rawLine;
const trimmed = line.trim();
if (trimmed.length === 0) {
const kind = commentStart >= 0 ? "comment" : "empty";
results.push({ text: null, value: null, kind, tokens: [], commentStart: commentStart >= 0 ? commentStart : null });
lineValues.push(null);
lineKinds.push("empty");
continue;
}
if (trimmed.startsWith("#")) {
results.push({ text: null, value: null, kind: "header", tokens: [], commentStart: commentStart >= 0 ? commentStart : null });
lineValues.push(null);
lineKinds.push("header");
continue;
}
const tokens = tokenize(line, this.reg);
const knownVars = new Set(vars.keys());
const parsed = parseLine(tokens, knownVars, line);
let value: Value | null = null;
if (parsed.expr) {
const ctx: EvalCtx = {
reg: this.reg,
vars,
line: { lineValues, lineKinds, index: i },
};
try {
value = evaluate(parsed.expr, ctx);
// a line without a result beats a dead sheet — swallow everything,
// including extension bugs and BigInt conversion errors
} catch (e) {
if (!(e instanceof EvalError)) console.warn("evaluate failed:", e);
value = null;
}
if (value?.kind === "quantity" && !value.value.isFinite()) value = null;
if (value && parsed.assign) vars.set(parsed.assign, value);
}
lineValues.push(value);
lineKinds.push("normal");
results.push({
text: value ? formatValue(value, this.settings) : null,
value,
kind: "normal",
tokens,
commentStart: commentStart >= 0 ? commentStart : null,
});
}
return results;
}
/** Evaluate a single expression (used by the extension runtime and tests). */
evaluateExpression(text: string): Value | null {
const r = this.evaluateDocument(text);
return r[r.length - 1]?.value ?? null;
}
/**
* Grand total of the sheet for the status bar: sums every line result
* compatible with the first united result's dimension (converted to its
* unit). Returns null when fewer than two lines contribute.
*/
totalOf(results: LineResult[]): string | null {
const qs = results
.map((r) => r.value)
.filter((v): v is Quantity => v !== null && v.kind === "quantity");
if (qs.length < 2) return null;
const anchor = qs.find((q) => q.unit) ?? qs[0];
let acc = new Decimal(0);
let count = 0;
for (const q of qs) {
if (anchor.unit && q.unit && q.unit.dimension === anchor.unit.dimension) {
acc = acc.add(toBase(q));
count++;
} else if (!anchor.unit && !q.unit) {
acc = acc.add(q.value);
count++;
}
}
if (count < 2) return null;
const value = anchor.unit ? fromBase(acc, anchor.unit) : acc;
return formatValue(qty(value, anchor.unit ?? null), this.settings);
}
// ---------- extension API (numi.*)
setVariable(name: string, value: number | ExtensionValue): void {
this.globals.set(name, this.toValue(value));
}
addUnit(spec: ExtensionUnitSpec): void {
const base = this.reg.unitsById.get(spec.baseUnitId) ?? this.currencyBase(spec.baseUnitId);
if (!base) throw new Error(`numi.addUnit: unknown baseUnitId ${spec.baseUnitId}`);
const unit: Unit = {
id: spec.id,
dimension: base.dimension,
ratio: base.ratio.mul(spec.ratio),
...(base.offset ? { offset: base.offset } : {}),
format: spec.format ?? spec.id,
};
this.reg.unitsById.set(unit.id, unit);
for (const p of spec.phrases.split(",")) {
const phrase = p.trim();
if (phrase) this.reg.addPhrase(phrase, { t: "unit", unit }, { caseSensitive: false });
}
this.reg.completions.push({ label: spec.id, type: "unit", detail: "extension" });
}
addFunction(spec: { id: string; phrases: string }, fn: (values: ExtensionValue[]) => ExtensionValue | number): void {
const name = spec.id;
this.reg.customFuncs.set(name, (args: Value[]) => {
const mapped = args.map((a) => this.fromValue(a));
return this.toValue(fn(mapped));
});
for (const p of (spec.phrases || spec.id).split(",")) {
const phrase = p.trim();
if (phrase) this.reg.addPhrase(phrase, { t: "func", name }, { caseSensitive: false });
}
this.reg.completions.push({ label: name, type: "function", detail: "extension" });
}
private currencyBase(code: string): Unit | null {
return this.reg.currencyCodes.has(code) ? this.reg.makeCurrencyUnit(code) : null;
}
private toValue(v: number | ExtensionValue): Value {
if (typeof v === "number") return qty(v);
const unit = v.unitId
? this.reg.unitsById.get(v.unitId) ?? this.currencyBase(v.unitId) ?? null
: null;
return qty(new Decimal(v.double), unit);
}
private fromValue(v: Value): ExtensionValue {
if (v.kind === "quantity") {
return { double: v.value.toNumber(), ...(v.unit ? { unitId: v.unit.id } : {}) };
}
if (v.kind === "percent") return { double: v.value.div(100).toNumber() };
return { double: v.ms };
}
}
export { formatValue } from "./formatter";
export type { Value, EngineSettings } from "./types";
export type { Token } from "./tokenizer";