replaces new Function() in the main window; per-script and per-call interrupt deadlines, memory and stack limits, graceful skip if the wasm module fails to load. csp script-src drops 'unsafe-eval' for 'wasm-unsafe-eval'.
16 KiB
Architecture
For people who want to read or change the code. The app is two parts: a TypeScript calculation engine + UI running in WebView2, and a thin Rust shell (Tauri 2) for everything OS-specific.
src/
engine/ the calculator itself (pure TS, no DOM)
lexer.ts line → raw lexemes (numbers, words, symbols)
vocab-data.ts en+ru word tables: unit names, currencies, operators
vocab.ts merged lookup over the tables
unitdata.ts conversion ratios (base units per dimension)
extraunits.ts speed/pressure/energy/power/frequency/fuel + crypto list
registry.ts phrase → token payload; built once at startup
tokenizer.ts lexemes + registry → semantic tokens
parser.ts tokens → AST (noise-tolerant Pratt parser)
evaluator.ts AST + document context → values with units
formatter.ts values → display strings
datetime.ts timezone map, calendar arithmetic
index.ts SumEngine facade + extension API + sheet totals
ui/
editor.ts CodeMirror 6 wiring: highlight, results overlay, copy
search.ts cross-sheet search overlay (text + result-value queries)
export.ts renders the sheet + results to a PNG (clipboard/file)
app.css themes (light/dark via CSS variables)
main.ts app bootstrap: documents, settings, status bar, dnd
storage.ts Tauri commands / localStorage fallback
extensions.ts runs user .js files in a QuickJS/WASM sandbox, bridged
to the engine's numi.* API
i18n.ts interface strings (en/ru)
updater.ts checks tauri-plugin-updater, installs + relaunches
src-tauri/
src/main.rs tray, hide-to-tray, storage commands, rates fetching
(open.er-api.com + CoinGecko + Yahoo Finance v8 chart)
with cache, historical rates (frankfurter.dev, permanent
per-date cache), backups, data-folder migration,
file drops, plugin registration (autostart,
global-shortcut, single-instance, opener, dialog,
updater, process)
How a line becomes a result
- Lexer splits
"5 inches in cm"into lexemes:5,inches,in,cm. The same lexer segments vocabulary phrases, so matching is consistent. - Registry holds every phrase the engine knows, keyed by first lexeme.
Longest match wins; case-sensitive entries (
mvsM,kB) are tried before case-insensitive ones, and an ambiguous lowercase form (mmvsMm) simply doesn't get a case-insensitive fallback. - Tokenizer walks the lexemes, asks the registry, and emits typed tokens.
A couple of context hacks live here, e.g.
inright after a number at the end of a line means inches (6 ft 3 in). - Parser is a Pratt parser that tolerates noise: unknown words are
dropped before parsing (that's why
spent 20 on pizza plus 5works) — except words right after a conversion operator, which may name a timezone. Precedence, loosest to tightest: line-level conversions (in cmapplies to the merged line, so2 hours 30 minutes in minutesworks) → bitwise → additive → multiplicative (incl.20% of x) → unary → postfix (units,%,!,^) → primary. - Evaluator computes
{ value: Decimal, unit }triples. Same-dimension operands convert through base units (toBase/fromBase); temperatures carry an offset, fuel economy units can be reciprocal (mpg ↔ L/100km). The document context supplies variables and previous line results forsum/avg/prev. - Formatter renders the value with the unit's display format, precision and grouping from settings, or as hex/binary/fraction/roman/etc.
Numbers are decimal.js throughout — no binary float artifacts.
Adding things
A unit — add a row to UNIT_DATA in unitdata.ts (id, dimension, ratio
to the dimension base) and its names to vocab-data.ts under the matching
category ("myunit.variants": "name,names,имя", "myunit.format": "sym").
For a brand-new dimension, extend the Dimension union in types.ts and use
extraunits.ts — phrases live inline there.
A currency — fiat codes come from the Currency table in vocab-data.ts;
crypto goes in CRYPTO in extraunits.ts (code, CoinGecko id, phrases,
snapshot price) plus the mirror list in src-tauri/src/main.rs.
An operator/function — register the phrase in buildRegistry()
(registry.ts), add a token/AST case if it needs new syntax, implement in
evaluator.ts. Most word-operators are data, not code.
A timezone city — one line in the Z map in datetime.ts.
Engine gotchas
- Phrase conflicts are real: single letters are case-sensitive by design
(
mmeter /Mmillion,kthousand /Kkelvin). Prefix generation is filtered where it collides with words — e.g. seconds only get ms/µs/ns/ps because an "attosecond" symbol would shadow the wordas. and/orare dual-purpose:andadds (5 and 3= 8),oris bitwise.of/off/ononly act on a percent left-hand side; otherwise they're prose. The sameontoken doubles as the historical-rate date separator (in EUR on 2024-01-01) —parseSeqchecks forpctop(on)+datelitright after a currency conv target and consumes both when present.- Currency rates are "units per USD"; a currency Unit's ratio is computed at
evaluation time from the current rate map, so re-setting rates re-prices
everything on the next evaluation. Historical rates live in a separate
historicalRates: Map<date, Map<code, number>>onSumEngineand are passed throughEvalCtxtoevalConv. - Live market data (stocks, commodities via Yahoo Finance) is merged into the
same rate map as exchange rates via
applyAllRates()inmain.tsto avoid onesetRates()call overwriting the other. - Goal seek (
? * 1.2 = 1000) tokenizes?as{ t: "unknown" }, parsed into{ k: "unknown" }nodes.parseLinedetects the presence ofunknownassignand builds a{ k: "goalseek", lhs, rhs }node. The solver tries a linear probe (f(0), f(1) → exact root for affine expressions) and falls back to bisection for nonlinear ones.
ChartValue(kind: "chart") is returned byevalAgg("chart", …)and bypassesformatValue— the results overlay renders an SVG sparkline instead of a text string.- The registry is rebuilt never; extensions mutate it (add phrases/functions)
at startup.
buildRegistry()(registry.ts) is a thin orchestrator overregisterCoreVocab/registerUnits/registerAreaVolume/registerCurrencies/registerExtraUnits/registerCrypto/registerCompletions— call order matters, sinceRegistry.addPhrasekeeps the first registration of an identical phrase and drops later ones (core vocab must win over unit/currency phrases that happen to collide). - Multiplying two same-dimension quantities (
2 kg * 500 g) converts the right operand into the left operand's unit before multiplying, so the result keeps a sane unit (1 kg, not1000in akglabel). Cross-dimension multiplication ($12 * 3,5 hours * 2) is unaffected — the right side is treated as a plain scale factor there. - A
//only starts a comment at the start of a line or after whitespace (findCommentStartinindex.ts), so a URL likesee https://example.comisn't truncated at the double slash. - Date literals and formats. Besides ISO (
2024-01-01), the lexer acceptsD.M.YYYY/M/D/YYYY(31.12.2024,12/31/2024) with the same separator on both sides and a 4-digit year. Day/month order is disambiguated per literal: a component>12fixes which side is the day; if both are≤12, the engine'sdateFormatsetting decides. All three literal forms reject out-of-range components via a round-trip check (validDateMsinlexer.ts—new Date(y, mo-1, d)silently rolls over invalid dates like2026-13-45instead of erroring, so the constructed date's year/month/day are compared back against the input).SumEngineresolvesdateFormat: "system"to an actual day/month order once viadetectDateOrder()(datetime.ts, probesIntl.DateTimeFormaton a2000-12-31fixture) and passes it intotokenize()→lexLine()on every call;"dmy"/"mdy"settings skip detection. Display works the same way in reverse:formatter.tseither uses the OS-localeIntlformatting ("system") or builds a fixedYYYY-MM-DD/DD.MM.YYYY/MM/DD/YYYYstring viaIntl.DateTimeFormat(...).formatToParts()(needed to respect the value's own IANA timezone, which a hand-rolledms-based formatter can't do).Intl.DateTimeFormatinstances are cached by options key informatter.ts(dtf()) since constructing one is comparatively expensive and every dated line rebuilds its format on every render. - Timezone name parsing (
time in Europe/Berlin) has two independent traps. First,parseLine's noise-filter only keeps word tokens that immediately follow aconvtoken (in/as/...); any other token type resets that state — a bare/between two timezone words would normally drop the second word before the parser ever sees it, so the filter special-cases a/that appears while collecting post-conv words. Second,parseTarget()'s word-collecting loop mirrors that same allowance so it keeps consuming past the/instead of stopping at the first word. Both places need the exception — fixing only one still dropsEurope/Berlindown toEurope.
Cross-sheet references
@Sheet.key is lexed directly in lexer.ts (not through the phrase
registry — sheet titles are arbitrary user text, not vocabulary), producing
an xref lexeme carrying { sheet, key }. The engine only knows how to
ask for a value: EvalCtx.resolveXRef?.(sheet, key) returns
{ ok: true; value } | { ok: false; reason }; when no resolver is supplied
(tests, extensions, evaluateExpression) an xref is simply unresolved.
Unresolved xrefs throw XRefError internally, caught in
SumEngine.evaluateDocument and surfaced as LineResult.error (rendered as
#ref? in the results column) — every other evaluation error still resolves
to a silent blank line, unchanged.
The engine has no concept of "other sheets" — that lives entirely in
src/workspace.ts. Workspace reads all sheet texts from the app's
in-memory data.contents, caches each sheet's exports (its assigned
variables, total, last) after first evaluating it, and invalidates a
sheet's cache plus every transitive dependent (found via a text scan for
@Name.) when it's edited or rates refresh. Workspace.evaluateSheet(id, text) pushes id onto a resolving-stack for the duration of the call, so a
sheet that references itself — directly or through another sheet — hits an
already-on-the-stack check and resolves to circular reference instead of
recursing forever.
UI notes
- CodeMirror injects its base theme at runtime after
app.css, so anything that must win (content padding for the results column, cursor color) lives in anEditorView.theme(...)extension, not the stylesheet. - The results column is an absolutely-positioned overlay; each result is
placed with
view.lineBlockAt(...)screen coordinates and re-rendered on scroll/geometry changes. Click-to-copy readsdata-value. - Engine evaluation is synchronous on every keystroke — a full sheet parse is well under a millisecond, so there's no debounce or worker.
- Search-all-sheets (
Ctrl+F) is a plain line scan over every open sheet's text, not a CodeMirror search extension — it has to cross documents, so it never touches the active editor until a result is clicked (editor.goToLine). It lives insrc/ui/search.ts(initSearch()), which owns the#search-overlayDOM and its own 150 ms input debounce — a result-value query (>1000) re-evaluates every open sheet, so debouncing matters once there are more than a couple of sheets open. - The settings popover is split into four tabs (General / Format / Data /
Updates) in
index.html, each a.settings-tabpaneltoggled by a#settings-tabs .tabbutton inbindSettingsUI()(main.ts) — plainclassList.toggle, no router. Tab styling reuses the sidebar'sdoc-item.activeidiom (background: var(--sel)for the active pill) rather than introducing a new visual pattern.
Storage
settings.jsonalways sits in%APPDATA%/app.summarum.calc.documents.json(all sheets) +backups/live either there or in the user-chosen data folder (the path is stored in settings).- Daily snapshot at startup (
backups/documents-YYYY-MM-DD.json, keep 14); deleted sheets become.numifiles underbackups/deleted/and are pruned by age. Saving is debounced at 400 ms.
Auto-update
updater.tscallstauri-plugin-updater'scheck()on boot; if a signed update is available the user is prompted, thendownloadAndInstall()+tauri-plugin-process'srelaunch().checkForUpdate()returnsAvailableUpdate | null | "error"—nullmeans the check succeeded and found nothing newer,"error"means the check itself failed (offline, signature mismatch, etc.); the distinction only matters for the manual path below. Every outcome is now visible in devtools: failures viaconsole.warn, and a successful "no update" result logs the running version alongside "that's the latest published release" viaconsole.info— the most common false alarm is simply that the installed build already is the newest release (e.g. right after installing it), which used to be indistinguishable from a silently broken check.- Closing the window hides it to the tray instead of exiting the process (see
on_window_eventinmain.rs), so a boot-time-only check can go unnoticed for a long time on a machine that isn't restarted often.main.tsalso rerunscheckUpdate()on asetInterval(every 6 hours) so a long-lived tray process still notices new releases, and Settings → Updates has a manual "Check for updates" button (checkUpdate(true)) that reports "up to date" / "check failed" via toast — those toasts are suppressed on the automatic background checks so the app doesn't nag every few hours. ASettingsData.autoUpdateEnabledtoggle turns off both the boot check and the periodic recheck (re-read fromsettingson every interval tick, so flipping it while the app is running takes effect immediately); the manual button ignores the toggle and always works. - The update endpoint is
latest.jsonpublished alongside each GitHub Release.release.ymlsigns both architectures with a minisign keypair: the public half is embedded intauri.conf.json; the private half + its password live only asTAURI_SIGNING_PRIVATE_KEY(_PASSWORD)repo secrets (write-only — there is no durable copy in the repo). Losing that key means already-installed copies can never trust a future signed release again.
Storage (cache files)
All files below live in %APPDATA%/app.summarum.calc (or the user-chosen folder):
| File | Contents | TTL |
|---|---|---|
settings.json |
app settings | persisted |
documents.json |
all sheets | persisted (400 ms debounce) |
rates.json |
live exchange rates (open.er-api.com + CoinGecko) | 1 hour |
market.json |
stock/commodity prices (Yahoo Finance v8 chart, one request per symbol) | 15 minutes |
rates-YYYY-MM-DD.json |
historical ECB rates for one date (frankfurter.dev) | permanent |
backups/documents-YYYY-MM-DD.json |
daily snapshots | 14 days |
backups/deleted/*.numi |
soft-deleted sheets | configurable |
Tests
npm test runs 259 vitest cases: src/engine/*.test.ts covers every
expression class, both languages, deterministic injected rates, goal seek,
historical rates (injected), date-format literals/display (dedicated engines
with an explicit dateFormat, since the default "system" format depends on
the CI machine's locale), plus a regression suite covering all known-fixed
bugs; src/workspace.test.ts covers cross-sheet references, caching and
cycle detection. UI is exercised manually; the engine is where the complexity
lives.