@jarenjs/calc 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +60 -0
  2. package/dist/types/ast.d.ts +65 -0
  3. package/dist/types/compile.d.ts +52 -0
  4. package/dist/types/component/index.d.ts +259 -0
  5. package/dist/types/component/rates/binance.d.ts +34 -0
  6. package/dist/types/component/rates/coingecko.d.ts +47 -0
  7. package/dist/types/component/rates/index.d.ts +84 -0
  8. package/dist/types/component/rules.d.ts +180 -0
  9. package/dist/types/component/schema.d.ts +38 -0
  10. package/dist/types/env.d.ts +24 -0
  11. package/dist/types/errors.d.ts +18 -0
  12. package/dist/types/index.d.ts +42 -0
  13. package/dist/types/modes/converter.d.ts +46 -0
  14. package/dist/types/modes/financial.d.ts +59 -0
  15. package/dist/types/modes/index.d.ts +38 -0
  16. package/dist/types/modes/programmer.d.ts +50 -0
  17. package/dist/types/modes/scientific.d.ts +28 -0
  18. package/dist/types/modes/standard.d.ts +32 -0
  19. package/dist/types/parser/index.d.ts +32 -0
  20. package/dist/types/plot/plot2d.d.ts +70 -0
  21. package/dist/types/plot/plot3d.d.ts +68 -0
  22. package/dist/types/render/error.d.ts +19 -0
  23. package/dist/types/theme.d.ts +35 -0
  24. package/dist/types/to-expr.d.ts +13 -0
  25. package/dist/types/utils.d.ts +9 -0
  26. package/docs/CALC-FORMAT.md +79 -0
  27. package/package.json +71 -0
  28. package/schemas/financial-inputs.schema.json +15 -0
  29. package/schemas/jaren-calc-ast.schema.json +91 -0
  30. package/schemas/jaren-calc-state.schema.json +52 -0
  31. package/src/ast.js +96 -0
  32. package/src/compile.js +119 -0
  33. package/src/component/index.js +352 -0
  34. package/src/component/rates/binance.js +44 -0
  35. package/src/component/rates/coingecko.js +63 -0
  36. package/src/component/rates/index.js +116 -0
  37. package/src/component/rules.js +118 -0
  38. package/src/component/schema.js +24 -0
  39. package/src/env.js +163 -0
  40. package/src/errors.js +23 -0
  41. package/src/index.js +85 -0
  42. package/src/modes/converter.js +73 -0
  43. package/src/modes/financial.js +89 -0
  44. package/src/modes/index.js +24 -0
  45. package/src/modes/programmer.js +69 -0
  46. package/src/modes/scientific.js +31 -0
  47. package/src/modes/standard.js +39 -0
  48. package/src/parser/index.js +220 -0
  49. package/src/plot/plot2d.js +221 -0
  50. package/src/plot/plot3d.js +177 -0
  51. package/src/render/error.js +25 -0
  52. package/src/theme.js +76 -0
  53. package/src/to-expr.js +84 -0
  54. package/src/utils.js +11 -0
  55. package/styles/calc.css +128 -0
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @file The x·y·z surface plotter. Samples
3
+ * `z = f(x, y)` on an N×N grid into `Vec3f64` model points, rotates them
4
+ * (yaw/pitch) and projects them with the `core` `mat4`/`project.js`
5
+ * kernel, builds quad faces, **depth-sorts back-to-front (painter's
6
+ * algorithm)**, shades each quad by height and emits `<polygon>`s with an
7
+ * optional wireframe. Deterministic and headless → golden-geometry tests.
8
+ */
9
+ export type Plot3dConfig = {
10
+ /**
11
+ * the z = f(x,y) source
12
+ */
13
+ expr?: string;
14
+ domainX?: [number, number];
15
+ domainY?: [number, number];
16
+ /**
17
+ * samples per axis
18
+ */
19
+ grid?: number;
20
+ yaw?: number;
21
+ /**
22
+ * rotation, radians
23
+ */
24
+ pitch?: number;
25
+ width?: number;
26
+ height?: number;
27
+ /**
28
+ * independent variable names
29
+ */
30
+ variables?: [string, string];
31
+ scope?: any;
32
+ env?: any;
33
+ theme?: any;
34
+ wireframe?: boolean;
35
+ };
36
+ /**
37
+ * @typedef {object} Plot3dConfig
38
+ * @property {string} [expr] the z = f(x,y) source
39
+ * @property {[number,number]} [domainX] @property {[number,number]} [domainY]
40
+ * @property {number} [grid] samples per axis
41
+ * @property {number} [yaw] @property {number} [pitch] rotation, radians
42
+ * @property {number} [width] @property {number} [height]
43
+ * @property {[string,string]} [variables] independent variable names
44
+ * @property {any} [scope] @property {any} [env]
45
+ * @property {any} [theme] @property {boolean} [wireframe]
46
+ */
47
+ /**
48
+ * Build the projected, depth-sorted scene (geometry as plain JSON).
49
+ * @param {string|Plot3dConfig} exprOrConfig
50
+ * @param {Plot3dConfig} [options]
51
+ * @returns {any}
52
+ */
53
+ export declare function buildScene3d(exprOrConfig: string | Plot3dConfig, options?: Plot3dConfig): any;
54
+ /**
55
+ * Render a 3D scene into pure-vnode SVG.
56
+ * @param {any} scene @param {{ theme?: any }} [options]
57
+ * @returns {any}
58
+ */
59
+ export declare function scene3dToVnode(scene: any, options?: {
60
+ theme?: any;
61
+ }): any;
62
+ /**
63
+ * Compile → sample → project → SVG vnode in one call.
64
+ * @param {string|Plot3dConfig} exprOrConfig
65
+ * @param {Plot3dConfig} [options]
66
+ * @returns {any}
67
+ */
68
+ export declare function plot3d(exprOrConfig: string | Plot3dConfig, options?: Plot3dConfig): any;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @file The error vnode. The render path is total — it emits this
3
+ * instead of throwing. A small standalone `<svg>` that
4
+ * states the parse/eval error with its line/column when known.
5
+ */
6
+ /**
7
+ * @param {{ message: string, line?: number, column?: number }} error
8
+ * @param {{ theme?: any, width?: number, height?: number }} [options]
9
+ * @returns {any}
10
+ */
11
+ export declare function errorToVnode(error: {
12
+ message: string;
13
+ line?: number;
14
+ column?: number;
15
+ }, options?: {
16
+ theme?: any;
17
+ width?: number;
18
+ height?: number;
19
+ }): any;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @file Plot theme tokens (mirrors `@jarenjs/mermaid`'s theme). Resolves a
3
+ * name (or overrides) into concrete colors (written as SVG presentation
4
+ * attributes so `toSvgString()` is a valid standalone image) plus a
5
+ * matching `--calc-*` CSS variable set stamped inline on the root `<svg>`.
6
+ * The inline stamp beats every stylesheet rule, so the stamp itself is the
7
+ * re-theming hook: the `'host'` theme stamps linked variables as
8
+ * `var(--<host-token>, <concrete>)` (see `HOST_VARS`), making plots follow
9
+ * a host's light/dark tokens live without a re-render. Only the token
10
+ * tables live here; the resolution mechanics are shared
11
+ * (`@jarenjs/view/helpers` `resolveTheme`).
12
+ */
13
+ /** @type {Record<string, Record<string, string>>} */
14
+ declare const THEMES: Record<string, Record<string, string>>;
15
+ /**
16
+ * Host custom-property links for the `'host'` theme: token key → the host
17
+ * token it should follow (the site token vocabulary, docs/DESIGN.md §2). The
18
+ * default theme's concrete colors remain as `var()` fallbacks, so the
19
+ * same SVG is standalone-valid outside any host. Tokens with no host
20
+ * equivalent (series2, the 3-D surface shades, wire) stay concrete.
21
+ * @type {Record<string, string>}
22
+ */
23
+ export declare const HOST_VARS: Record<string, string>;
24
+ /**
25
+ * Resolve a theme name or override object. The name `'host'` resolves the
26
+ * default tokens linked to the host token vocabulary via {@link HOST_VARS}.
27
+ * @param {string | Record<string, any>} [nameOrOverrides]
28
+ * @returns {{ name: string, tokens: Record<string, string>, cssVars: Record<string, string> }}
29
+ */
30
+ export declare function createTheme(nameOrOverrides?: string | Record<string, any>): {
31
+ name: string;
32
+ tokens: Record<string, string>;
33
+ cssVars: Record<string, string>;
34
+ };
35
+ export { THEMES };
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @file `toExpression(ast)` — the canonical printer. It is a **round-trip
3
+ * fixed point**: `parseExpression(toExpression(ast))` deep-equals
4
+ * `ast` for every AST the parser can produce. Parentheses are emitted
5
+ * from operator precedence/associativity only where removing them would
6
+ * change the parse; numbers print in canonical decimal (the AST never
7
+ * carried their original radix).
8
+ */
9
+ /**
10
+ * @param {any} node
11
+ * @returns {string}
12
+ */
13
+ export declare function toExpression(node: any): string;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @file Small shared helpers for the calc engine.
3
+ *
4
+ * The content hash is the suite's single fingerprint primitive, so calc
5
+ * re-exports the one implementation from `@jarenjs/core` rather than
6
+ * carrying its own copy: equal content hits the same O(1) fast path
7
+ * (vnode `key`, memo key) everywhere downstream.
8
+ */
9
+ export { hashContent } from '@jarenjs/core/string';
@@ -0,0 +1,79 @@
1
+ # CALC-FORMAT — the `@jarenjs/calc` expression AST & mode contract
2
+
3
+ `@jarenjs/calc` is a two-layer package: a pure
4
+ **engine** (`src/*`, knows only `@jarenjs/core` + `@jarenjs/view`) and a
5
+ **component** (`src/component/`, adds `@jarenjs/app` + `@jarenjs/forms`).
6
+ This document is the engine contract.
7
+
8
+ ## Expression AST
9
+
10
+ `parseExpression(text) → ExprAST` is a char-offset recursive-descent,
11
+ precedence-climbing parser (no `eval`, no `new Function`). Nodes are
12
+ monomorphic plain objects tagged by `type`; the AST is **geometry-free**
13
+ and does not preserve a literal's radix or source spacing. Node kinds:
14
+
15
+ | kind | shape |
16
+ |------|-------|
17
+ | `num` | `{ type:'num', value }` |
18
+ | `const` | `{ type:'const', name }` — `pi`,`e`,`phi`,`tau`,`inf`,`nan` (+ `π φ τ`) |
19
+ | `var` | `{ type:'var', name }` — free identifiers (`x`,`y`,`ans`,…) |
20
+ | `unary` | `{ type:'unary', op, arg }` — `- + ~` (prefix) |
21
+ | `postfix` | `{ type:'postfix', op, arg }` — `!` (factorial), `%` (percent) |
22
+ | `binary` | `{ type:'binary', op, left, right }` — `+ - * / ^ & \| << >>` |
23
+ | `call` | `{ type:'call', name, args }` |
24
+
25
+ The schema `schemas/jaren-calc-ast.schema.json` (draft-neutral) validates it.
26
+
27
+ ### Grammar / precedence (low → high)
28
+
29
+ `|` → `&` → `<< >>` → `+ -` → `* /` → prefix `- + ~` → `^` (right-assoc) →
30
+ postfix `! %` → primary. Power binds tighter than unary minus
31
+ (`-2^2 = -(2^2)`); `^` is exponentiation in **every** mode.
32
+
33
+ ### Round-trip fixed point
34
+
35
+ `toExpression(ast)` is a canonical printer for which
36
+ `parseExpression(toExpression(ast))` deep-equals `ast` across the fixture
37
+ corpus, and `toExpression` is idempotent on its own output. Parentheses
38
+ are emitted from precedence/associativity only where they change the parse.
39
+
40
+ ## Compilation & evaluation
41
+
42
+ `compileExpr(ast, { env }) → (scope) => number` bakes every dispatch
43
+ decision into a nested closure (second compiler stage). `evaluate(source,
44
+ scope?, { env }?)` is the error-safe front door, returning
45
+ `{ ok:true, value } | { ok:false, error:{ message, line?, column? } }` —
46
+ it never throws into the app path. `scope` supplies variables (`x`, `y`,
47
+ `ans`, …) and runtime flags (`angleMode`, `wordBits`, `signed`).
48
+
49
+ **Environments** (the binding sets a mode resolves against): `defaultEnv()`
50
+ is float/scientific (angle-aware trig, `^` = power); `programmerEnv()`
51
+ overrides `& | << >> ~` with word-masked BigInt math from
52
+ `@jarenjs/core/math/word.js` and adds `and/or/xor/not/shl/shr/rol/ror/mod`.
53
+
54
+ > Bitwise **XOR** is the `xor(a, b)` function (not an infix operator), so
55
+ > `^` stays exponentiation everywhere. Programmer results are surfaced as
56
+ > `Number`; values beyond 2^53 lose precision when read back as a float —
57
+ > the four-base display uses `word.js` on BigInt directly and stays exact.
58
+
59
+ ## Modes
60
+
61
+ Each mode (`standard`, `scientific`, `programmer`, `financial`,
62
+ `converter`) is a data-driven descriptor: a keypad/panel (`{ label, k,
63
+ tone?, span? }` rows, where `k` is the token appended to the expression
64
+ entry or a command `clear`/`back`/`equals`), a function-binding env, and a
65
+ formatter. `financial` holds **no formulas** (it orchestrates
66
+ `@jarenjs/core/finance` via `solveTvm`/`buildAmortization`/`npvOf`/`irrOf`);
67
+ `converter` holds **no factors** (it orchestrates `@jarenjs/core/convert`
68
+ via `convertValue`, currency included).
69
+
70
+ ## Plotting
71
+
72
+ `plot2d`/`plot3d` (and `calcToVnode`) produce pure-vnode SVG rooted at
73
+ `['svg', …]`. They build a deterministic **scene** (geometry as plain JSON
74
+ — golden-testable) then render it. 2D compiles `f(x)` once, samples into a
75
+ `Float64Array`, maps to the viewport and breaks the path on NaN/±Inf and
76
+ asymptote jumps. 3D samples `z = f(x,y)` on a grid, rotates/projects with
77
+ the core `mat4`/`project.js` kernel, builds quads, **depth-sorts
78
+ back-to-front (painter's algorithm)** and shades by height.
79
+ `toSvgString(vnode)` gives a standalone SSR string.
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@jarenjs/calc",
3
+ "private": false,
4
+ "version": "0.34.0",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "types": "./dist/types/index.d.ts",
8
+ "sideEffects": false,
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/types/index.d.ts",
12
+ "default": "./src/index.js"
13
+ },
14
+ "./component": {
15
+ "types": "./dist/types/component/index.d.ts",
16
+ "default": "./src/component/index.js"
17
+ },
18
+ "./theme": {
19
+ "types": "./dist/types/theme.d.ts",
20
+ "default": "./src/theme.js"
21
+ },
22
+ "./styles/calc.css": "./styles/calc.css",
23
+ "./schemas/*": "./schemas/*",
24
+ "./package.json": "./package.json"
25
+ },
26
+ "files": [
27
+ "dist/types/",
28
+ "src/",
29
+ "docs/",
30
+ "schemas/",
31
+ "styles/"
32
+ ],
33
+ "description": "A native, headless multi-mode calculator (standard / programmer / scientific / financial / converter) with x·y and x·y·z plotting rendered as pure-vnode SVG through @jarenjs/view — a two-stage expression compiler (parseExpression ⇄ toExpression), built on @jarenjs/core (math/finance/convert) and driven as an @jarenjs/app document.",
34
+ "author": "joham",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/jklarenbeek/jarenjs.git",
38
+ "directory": "components/calc"
39
+ },
40
+ "license": "MIT",
41
+ "engines": {
42
+ "node": ">=24"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public",
46
+ "registry": "https://registry.npmjs.org/"
47
+ },
48
+ "keywords": [
49
+ "jaren",
50
+ "json",
51
+ "calculator",
52
+ "expression",
53
+ "parser",
54
+ "plot",
55
+ "svg",
56
+ "headless",
57
+ "finance",
58
+ "units"
59
+ ],
60
+ "scripts": {
61
+ "build": "npm run build:types",
62
+ "build:types": "tsc -p tsconfig.json",
63
+ "prepack": "npm run build:types"
64
+ },
65
+ "dependencies": {
66
+ "@jarenjs/core": "^0.34.0",
67
+ "@jarenjs/view": "^0.34.0",
68
+ "@jarenjs/forms": "^0.34.0",
69
+ "@jarenjs/app": "^0.34.0"
70
+ }
71
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://jarenjs.dev/schemas/financial-inputs.schema.json",
4
+ "title": "Time Value of Money",
5
+ "type": "object",
6
+ "properties": {
7
+ "nper": { "type": "number", "title": "N — number of periods", "minimum": 0 },
8
+ "rate": { "type": "number", "title": "I/Y — interest % per period" },
9
+ "pv": { "type": "number", "title": "PV — present value" },
10
+ "pmt": { "type": "number", "title": "PMT — payment" },
11
+ "fv": { "type": "number", "title": "FV — future value" },
12
+ "solveFor": { "title": "Solve for", "enum": ["pmt", "pv", "fv", "nper", "rate"] }
13
+ },
14
+ "required": ["solveFor"]
15
+ }
@@ -0,0 +1,91 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://jarenjs.dev/schemas/jaren-calc-ast.schema.json",
4
+ "title": "Jaren calc expression AST",
5
+ "description": "The geometry-free expression AST produced by parseExpression and consumed by compileExpr / toExpression. Draft-neutral (no $ref siblings, no unevaluated*, no $dynamic*).",
6
+ "$ref": "#/$defs/node",
7
+ "$defs": {
8
+ "node": {
9
+ "oneOf": [
10
+ { "$ref": "#/$defs/num" },
11
+ { "$ref": "#/$defs/const" },
12
+ { "$ref": "#/$defs/var" },
13
+ { "$ref": "#/$defs/unary" },
14
+ { "$ref": "#/$defs/postfix" },
15
+ { "$ref": "#/$defs/binary" },
16
+ { "$ref": "#/$defs/call" }
17
+ ]
18
+ },
19
+ "num": {
20
+ "type": "object",
21
+ "required": ["type", "value"],
22
+ "properties": {
23
+ "type": { "const": "num" },
24
+ "value": { "type": "number" }
25
+ },
26
+ "additionalProperties": false
27
+ },
28
+ "const": {
29
+ "type": "object",
30
+ "required": ["type", "name"],
31
+ "properties": {
32
+ "type": { "const": "const" },
33
+ "name": { "type": "string" }
34
+ },
35
+ "additionalProperties": false
36
+ },
37
+ "var": {
38
+ "type": "object",
39
+ "required": ["type", "name"],
40
+ "properties": {
41
+ "type": { "const": "var" },
42
+ "name": { "type": "string" }
43
+ },
44
+ "additionalProperties": false
45
+ },
46
+ "unary": {
47
+ "type": "object",
48
+ "required": ["type", "op", "arg"],
49
+ "properties": {
50
+ "type": { "const": "unary" },
51
+ "op": { "enum": ["-", "+", "~"] },
52
+ "arg": { "$ref": "#/$defs/node" }
53
+ },
54
+ "additionalProperties": false
55
+ },
56
+ "postfix": {
57
+ "type": "object",
58
+ "required": ["type", "op", "arg"],
59
+ "properties": {
60
+ "type": { "const": "postfix" },
61
+ "op": { "enum": ["!", "%"] },
62
+ "arg": { "$ref": "#/$defs/node" }
63
+ },
64
+ "additionalProperties": false
65
+ },
66
+ "binary": {
67
+ "type": "object",
68
+ "required": ["type", "op", "left", "right"],
69
+ "properties": {
70
+ "type": { "const": "binary" },
71
+ "op": { "enum": ["+", "-", "*", "/", "^", "&", "|", "<<", ">>"] },
72
+ "left": { "$ref": "#/$defs/node" },
73
+ "right": { "$ref": "#/$defs/node" }
74
+ },
75
+ "additionalProperties": false
76
+ },
77
+ "call": {
78
+ "type": "object",
79
+ "required": ["type", "name", "args"],
80
+ "properties": {
81
+ "type": { "const": "call" },
82
+ "name": { "type": "string" },
83
+ "args": {
84
+ "type": "array",
85
+ "items": { "$ref": "#/$defs/node" }
86
+ }
87
+ },
88
+ "additionalProperties": false
89
+ }
90
+ }
91
+ }
@@ -0,0 +1,52 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://jarenjs.dev/schemas/jaren-calc-state.schema.json",
4
+ "title": "Jaren calc state slice",
5
+ "description": "The plain-JSON $.calc state slice. Draft-neutral.",
6
+ "type": "object",
7
+ "properties": {
8
+ "mode": { "enum": ["standard", "scientific", "programmer", "financial", "converter"] },
9
+ "entry": { "type": "string" },
10
+ "ans": { "type": "number" },
11
+ "memory": { "type": "number" },
12
+ "angleMode": { "enum": ["rad", "deg", "grad"] },
13
+ "base": { "enum": ["HEX", "DEC", "OCT", "BIN"] },
14
+ "wordBits": { "enum": [8, 16, 32, 64] },
15
+ "signed": { "type": "boolean" },
16
+ "tape": {
17
+ "type": "array",
18
+ "items": {
19
+ "type": "object",
20
+ "properties": { "expr": { "type": "string" }, "result": { "type": "string" } }
21
+ }
22
+ },
23
+ "plot": {
24
+ "type": "object",
25
+ "properties": {
26
+ "expr": { "type": "string" },
27
+ "kind": { "enum": ["2d", "3d"] }
28
+ }
29
+ },
30
+ "fin": { "$ref": "financial-inputs.schema.json" },
31
+ "conv": {
32
+ "type": "object",
33
+ "properties": {
34
+ "dimension": { "type": "string" },
35
+ "from": { "type": "string" },
36
+ "to": { "type": "string" },
37
+ "value": { "type": "number" }
38
+ }
39
+ },
40
+ "rates": {
41
+ "type": "object",
42
+ "properties": {
43
+ "base": { "type": "string" },
44
+ "rates": { "type": "object" },
45
+ "at": { "type": "number" },
46
+ "stale": { "type": "boolean" },
47
+ "status": { "type": "string" }
48
+ }
49
+ }
50
+ },
51
+ "required": ["mode", "entry"]
52
+ }
package/src/ast.js ADDED
@@ -0,0 +1,96 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The expression AST. Monomorphic node
4
+ * constructors — one plain-object shape per kind — so the compiler and
5
+ * printer branch on a single `type` tag and the reconciler/`deepEqual`
6
+ * round-trip stays cheap. The AST is **geometry-free**: it carries no
7
+ * layout, no source formatting (not even a literal's original radix), so
8
+ * that `parseExpression(toExpression(ast))` deep-equals `ast`. Meaning is
9
+ * imposed later — by `compileExpr` (evaluation) or the plotter
10
+ * (projection), never by the parser.
11
+ */
12
+
13
+ export const CALC_AST_VERSION = '0.13.0';
14
+
15
+ /** Numeric literal (value only — the radix/format is not preserved). */
16
+ export function num(value) {
17
+ return { type: 'num', value: +value };
18
+ }
19
+
20
+ /** A named constant (`pi`, `e`, `phi`, `tau`, `inf`, `nan`). */
21
+ export function constant(name) {
22
+ return { type: 'const', name };
23
+ }
24
+
25
+ /** A variable / free identifier (`x`, `y`, `ans`, `mem`). */
26
+ export function variable(name) {
27
+ return { type: 'var', name };
28
+ }
29
+
30
+ /** A prefix unary node (`-`, `+`, `~`). */
31
+ export function unary(op, arg) {
32
+ return { type: 'unary', op, arg };
33
+ }
34
+
35
+ /** A postfix node (`!` factorial, `%` percent). */
36
+ export function postfix(op, arg) {
37
+ return { type: 'postfix', op, arg };
38
+ }
39
+
40
+ /** A binary node (`+ - * / ^ & | << >>`). */
41
+ export function binary(op, left, right) {
42
+ return { type: 'binary', op, left, right };
43
+ }
44
+
45
+ /** A function call (`sin(x)`, `log(2, 8)`, `xor(a, b)`). */
46
+ export function call(name, args) {
47
+ return { type: 'call', name, args };
48
+ }
49
+
50
+ /**
51
+ * Free identifiers that always denote a constant rather than a variable.
52
+ * @type {Record<string, number>}
53
+ */
54
+ export const CONSTANTS = {
55
+ pi: Math.PI,
56
+ π: Math.PI,
57
+ tau: Math.PI * 2,
58
+ τ: Math.PI * 2,
59
+ e: Math.E,
60
+ phi: (1 + Math.sqrt(5)) / 2,
61
+ φ: (1 + Math.sqrt(5)) / 2,
62
+ inf: Infinity,
63
+ nan: NaN,
64
+ };
65
+
66
+ /** Is `name` a known constant identifier? */
67
+ export function isConstant(name) {
68
+ return Object.prototype.hasOwnProperty.call(CONSTANTS, name);
69
+ }
70
+
71
+ /**
72
+ * Structural equality for ASTs (used by the round-trip fixed-point
73
+ * tests). NaN compares equal to NaN so `nan` literals round-trip.
74
+ * @param {any} a @param {any} b
75
+ * @returns {boolean}
76
+ */
77
+ export function astEqual(a, b) {
78
+ if (a === b) return true;
79
+ if (typeof a === 'number' && typeof b === 'number') {
80
+ return a === b || (Number.isNaN(a) && Number.isNaN(b));
81
+ }
82
+ if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false;
83
+ if (Array.isArray(a) || Array.isArray(b)) {
84
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
85
+ for (let i = 0; i < a.length; i++) if (!astEqual(a[i], b[i])) return false;
86
+ return true;
87
+ }
88
+ const ka = Object.keys(a);
89
+ const kb = Object.keys(b);
90
+ if (ka.length !== kb.length) return false;
91
+ for (const k of ka) {
92
+ if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
93
+ if (!astEqual(a[k], b[k])) return false;
94
+ }
95
+ return true;
96
+ }
package/src/compile.js ADDED
@@ -0,0 +1,119 @@
1
+ //@ts-check
2
+ /**
3
+ * @file `compileExpr(ast, opts) → (scope) => number`. The second stage of
4
+ * the two-stage compiler: every dispatch decision — which operator
5
+ * closure, which function, whether an identifier is a constant — is made
6
+ * once, here, and baked into a nested closure. Evaluation then does no
7
+ * lookups on the hot path. No `eval`, no `new Function` (CSP-safe).
8
+ *
9
+ * `evaluate(source, scope?, opts?)` is the error-safe front door: it
10
+ * parses, compiles and runs, returning a tagged `{ ok, value } | { ok:
11
+ * false, error }` result so the app/render path never throws.
12
+ */
13
+
14
+ import { parseExpression } from './parser/index.js';
15
+ import { defaultEnv } from './env.js';
16
+ import { CalcParseError } from './errors.js';
17
+
18
+ /**
19
+ * Compile an AST against an environment.
20
+ * @param {any} ast
21
+ * @param {{ env?: any }} [opts]
22
+ * @returns {(scope?: any) => number}
23
+ */
24
+ export function compileExpr(ast, opts = {}) {
25
+ const env = opts.env ?? defaultEnv();
26
+ return build(ast, env);
27
+ }
28
+
29
+ /**
30
+ * @param {any} node
31
+ * @param {any} env
32
+ * @returns {(scope?: any) => number}
33
+ */
34
+ function build(node, env) {
35
+ switch (node.type) {
36
+ case 'num': {
37
+ const v = node.value;
38
+ return () => v;
39
+ }
40
+ case 'const': {
41
+ const name = node.name;
42
+ const v = env.constants[name];
43
+ if (v === undefined) throw new CalcParseError(`unknown constant '${name}'`);
44
+ return () => v;
45
+ }
46
+ case 'var': {
47
+ const name = node.name;
48
+ return (scope) => {
49
+ const v = scope ? scope[name] : undefined;
50
+ return v === undefined ? NaN : +v;
51
+ };
52
+ }
53
+ case 'unary': {
54
+ const op = env.unops[node.op];
55
+ if (op === undefined) throw new CalcParseError(`unknown unary operator '${node.op}'`);
56
+ const arg = build(node.arg, env);
57
+ return (scope) => op(arg(scope), scope);
58
+ }
59
+ case 'postfix': {
60
+ const op = env.postops[node.op];
61
+ if (op === undefined) throw new CalcParseError(`unknown postfix operator '${node.op}'`);
62
+ const arg = build(node.arg, env);
63
+ return (scope) => op(arg(scope), scope);
64
+ }
65
+ case 'binary': {
66
+ const op = env.binops[node.op];
67
+ if (op === undefined) throw new CalcParseError(`unknown operator '${node.op}'`);
68
+ const l = build(node.left, env);
69
+ const r = build(node.right, env);
70
+ return (scope) => op(l(scope), r(scope), scope);
71
+ }
72
+ case 'call': {
73
+ const fn = env.funcs[node.name];
74
+ if (fn === undefined) throw new CalcParseError(`unknown function '${node.name}'`);
75
+ const args = node.args.map((a) => build(a, env));
76
+ return (scope) => fn(args.map((a) => a(scope)), scope);
77
+ }
78
+ default:
79
+ throw new CalcParseError(`cannot compile node '${node.type}'`);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * @typedef {object} EvalOk
85
+ * @property {true} ok
86
+ * @property {number} value
87
+ */
88
+ /**
89
+ * @typedef {object} EvalErr
90
+ * @property {false} ok
91
+ * @property {{ message: string, line?: number, column?: number }} error
92
+ */
93
+
94
+ /**
95
+ * Parse + compile + evaluate a source expression, error-safe.
96
+ * @param {string} source
97
+ * @param {any} [scope]
98
+ * @param {{ env?: any }} [opts]
99
+ * @returns {EvalOk | EvalErr}
100
+ */
101
+ export function evaluate(source, scope = {}, opts = {}) {
102
+ try {
103
+ const ast = parseExpression(source, opts);
104
+ const fn = compileExpr(ast, opts);
105
+ const value = fn(scope);
106
+ return { ok: true, value };
107
+ }
108
+ catch (err) {
109
+ const e = /** @type {any} */ (err);
110
+ return {
111
+ ok: false,
112
+ error: {
113
+ message: e && e.message ? e.message : String(err),
114
+ line: e instanceof CalcParseError ? e.line : undefined,
115
+ column: e instanceof CalcParseError ? e.column : undefined,
116
+ },
117
+ };
118
+ }
119
+ }